Verlet 積分による揺れもの物理と、専用エディタ「JiggleBoneEditor」の実装
DirectX 11 + ozz-animation + Dear ImGui を用いて、髪・スカート・尻尾といった揺れもの (secondary motion) のパラメータ調整に特化したエディタ JiggleBoneEditor を実装しました。本記事では、その物理ソルバ JigglePhysics と専用エディタの設計および実装上の要点について述べます。
特に以下の3点に焦点を当てます。
- 命名規則による揺れもの対象ボーンの自動判別
- Verlet 積分を用いたモデル空間での物理計算
- 距離拘束とコライダー押出を組み合わせた反復ソルバ
目次
- 1. はじめに
- 2. S_ プレフィックスによる対象ボーンの自動判別
- 3. 物理シミュレーション
- 4. コライダー: Sphere / Capsule の統一表現
- 5. エディタ UI の概要
- 6. 設定ファイル形式 (.jiggle)
- 7. おわりに
1. はじめに
3D キャラクタアニメーションにおいて、髪・スカート・尻尾などの動きは、主動作 (走行・停止・振り向き等) から派生する二次運動 (secondary motion) として扱われます。これらをアニメータが毎フレーム手付けすることは現実的でないため、通常はランタイムでの物理シミュレーションによって生成します。
本プロジェクトでは、この二次運動を担うソルバを JigglePhysics として実装し、そのパラメータ調整を目的とした専用環境 JiggleBoneEditor を整備しました。エディタはゲーム本体とソルバ実装を共有しており、調整結果が本番ビルドにそのまま反映される構成となっています。
https://youtu.be/ygggheeixhw
ゲーム本体でのテスト動画です
2. S_ プレフィックスによる対象ボーンの自動判別
2.1 設計上の課題
揺れもの実装で最初に決定すべきは、スケルトン中のどのボーンを物理対象とするかを何によって判別するかです。本プロジェクトで採用した方式はボーン命名規則による自動判別です。「揺らしたいボーンは名前を S_ で始める」 という規約を採用しました。モデラ側の規約遵守が前提ですが、実装側はメンテゼロ というメリットがあります。
2.2 判別と粒子生成
判別関数は文字列比較のみで完結します。
inline bool IsJiggleName(const char* name) {
return name && name[0] == 'S' && name[1] == '_';
}
スケルトン走査時に該当ボーンを検出し、それぞれにパーティクル (質点) を一つ割り当てます。各パーティクルは「現在位置」「前ステップの位置」「親パーティクル参照」を保持し、後段の Verlet 積分の対象となります。
const int numJoints = skeleton->num_joints();
auto names = skeleton->joint_names();
auto parents = skeleton->joint_parents();
for (int i = 0; i < numJoints; ++i) {
if (!IsJiggleName(names[i])) continue;
Particle p;
p.jointIdx = i;
p.parentJoint = parents[i];
p.name = names[i];
m_particles.push_back(std::move(p));
}
2.3 ジョイント番号 → パーティクル番号の逆引きマップ
スケルトン上の親情報は ジョイントインデックス として記録されている一方、物理計算で必要となるのは パーティクルインデックス です。両者を変換する逆引きマップを構築します。
m_jointToParticle.assign(numJoints, -1);
// パーティクル生成時に書き込み
m_jointToParticle[i] = static_cast<int>(m_particles.size()) - 1;
// 全粒子について親パーティクルを解決
for (auto& p : m_particles) {
p.parentIdx = (p.parentJoint >= 0)
? m_jointToParticle[p.parentJoint]
: -1;
}
親ジョイントが S_ で始まらない場合 (例: S_Hair_R_01 の親が Head)、対応するパーティクルは存在しないため parentIdx = -1 となります。この値はそのまま チェーン始点フラグ として機能し、物理計算側では「親はアニメーション通りに動く一般ボーン」として扱われます。
結果として、髪・スカート・尻尾といったチェーン構造は、明示的なグルーピングなしに親子参照のみで自動構築されます。
2.4 エディタ側の自動同期
エディタ起動時に、スケルトン状態と設定ファイル (.jiggle) の差分を解消します。
// スケルトンに存在し、設定に未登録のボーンはデフォルト値で追加
for (const auto& name : names) {
if (!m_config.FindByName(name)) {
m_config.bones.push_back({.boneName = name});
}
}
// 設定に存在し、スケルトンから消えたボーンは除外
m_config.bones.erase(
std::remove_if(m_config.bones.begin(), m_config.bones.end(),
[&](const JiggleBoneParam& b) {
return std::find(names.begin(), names.end(), b.boneName) == names.end();
}),
m_config.bones.end());
モデル更新時の運用は以下のとおりに簡素化されます。
| 状況 | エディタの挙動 |
|---|---|
| S_ ボーンの追加 | デフォルト値で UI に自動登録 |
| S_ ボーンの削除 | 設定からも自動除外 |
| S_ ボーンのリネーム | 旧名のパラメータは失われる (新規ボーン扱い) |
リネーム時のみ手動移行を要する点を除き、命名規則のみでメンテナンスフリーな運用が成立します。
3. 物理シミュレーション
3.1 Verlet 積分の概観
本実装の物理ソルバは Verlet 積分 を採用しています。Verlet 積分は髪・布・ロープなど Position Based Dynamics (PBD) 系の手法において広く用いられています。
明示的 Euler 法では、速度を状態変数として保持し以下のように更新します。
$$
\vec{v}_{n+1} = \vec{v}_{n} + \vec{a}_{n} \Delta t
$$
$$
\vec{x}_{n+1} = \vec{x}_{n} + \vec{v}_{n+1} \Delta t
$$
これに対して Verlet 積分では、現在位置 $\vec{x}_{n}$ と前ステップ位置 $\vec{x}_{n-1}$ から次の位置を算出します。
$$
\vec{x}_{n+1} = 2 \vec{x}_{n} - \vec{x}_{n-1} + \vec{a}_{n} \Delta t^{2}
$$
速度は必要に応じて差分で逆算します。
$$
\vec{v}_{n} \approx \frac{\vec{x}_{n} - \vec{x}_{n-1}}{\Delta t}
$$
Verlet 積分を採用する主な理由は以下の通りです。
| 観点 | 利点 |
|---|---|
| 数値安定性 | 時間反転対称性により、長時間積分でもエネルギーが大きく発散しにくい |
| 拘束との親和性 | 位置を直接書き換えると、その変位が次ステップの速度として自動的に反映される |
| 実装コスト | 状態変数は位置 2 点のみ |
| パフォーマンス | 粒子数の多い系に対しても軽量 |
特に「位置の強制変更がそのまま速度の更新となる」性質は、距離拘束やコライダー押出と決定的に整合し、PBD 系手法のアルゴリズム的基盤を成しています。
実装では速度差分に減衰係数 $d \in [0, 1]$ を乗じています。
$$
\vec{v}_{n} = (\vec{x}_{n} - \vec{x}_{n-1})(1 - d)
$$
Vector3 velocity = (p.position - p.prevPosition) * (1.0f - p.params.damping);
p.prevPosition = p.position;
p.position += velocity + acceleration * (dt * dt);
$d = 0$ で無減衰 (永続振動)、$d = 1$ で即停止に相当します。
3.2 1 サブステップの処理フロー
固定タイムステップ $\Delta t = 1/240$ 秒で進行する1サブステップにおいて、各パーティクルに対し以下の処理が順に適用されます。(タイプステップの大きさは安定性とのトレードオフになりますが、240という数値で現状問題なさそうなので一旦このままの数値で運用していくことにしました。)
| 段階 | 処理 |
|---|---|
| (0) | アニメーション pose から rest length を再計算 |
| (1) | Verlet 統合 (慣性項) |
| (2) | 重力・風による加速度を加算 |
| (3) | バネ力による rest pose 相対位置への引き戻し |
| (4) | 距離拘束 + コライダー押出を 4 反復 |
| (5) | 円錐角度クランプ |
| (6) | NaN・発散時のフォールバック |
本節では (0) と (4) を重点的に解説します。
3.3 距離拘束と 4 反復による収束
rest length の定義
restLength を 「親パーティクル → 自パーティクルの本来の距離」 と定義します。Verlet 統合後の位置は外力等により親との距離が変動するため、これを restLength に再射影するのが 距離拘束 (distance constraint) です。
一般的な実装では rest length をモデルロード時に1度だけ算出しますが、本実装ではアニメーション中のボーンスケール変動 (Squash & Stretch) に追従させるため、毎サブステップで再キャプチャ します。
$$
L_{\mathrm{rest}}^{(n)} = \left\Vert \vec{a}_{\mathrm{self}}^{(n)} - \vec{a}_{\mathrm{parent}}^{(n)} \right\Vert
$$
ここで $\vec{a}^{(n)}$ はステップ $n$ におけるアニメーション上のジョイント位置です。
p.restLength = (animPos - parentAnimPos).Length(); // 毎ステップ更新
この設計により、物理は「絶対距離」ではなく 「現フレームのアニメ pose に対する相対形状」 を保つ挙動となります。
距離拘束の定式化
親パーティクル位置を $\vec{p}_{\mathrm{parent}}$、自パーティクル位置を $\vec{p}$ とすると、距離拘束後の位置 $\vec{p}^{\prime}$ は次式で与えられます。
$$
\hat{d} = \frac{\vec{p} - \vec{p}_{\mathrm{parent}}}{\left\Vert \vec{p} - \vec{p}_{\mathrm{parent}} \right\Vert}
$$
$$
\vec{p}^{\prime} = \vec{p}_{\mathrm{parent}} + L_{\mathrm{rest}} \hat{d}
$$
すなわち、親から見た方向を保ったまま距離成分のみを $L_{\mathrm{rest}}$ に射影します。
4 反復ソルバの必要性
距離拘束単独であれば一度の適用で収束しますが、コライダー押出と併用する場合は単発では収束しません。両制約は一般に同時に満たせず、片方を満たすともう片方が破れるためです。
for (int iter = 0; iter < 4; ++iter) {
// (a) 親に対して restLength の位置に再射影
Vector3 dir = p.position - parentPos;
float len = dir.Length();
if (len > 1e-6f) {
dir /= len;
p.position = parentPos + dir * p.restLength;
}
// (b) 全コライダーで押し出し
for (const auto& c : m_colliders) {
// 線分上の最近点を求めて push する処理 (詳細は §4)
}
}
反復回数と挙動の関係を経験的に整理すると次のようになります (便宜上 2 の冪刻みで列挙していますが、実選定は経験的に決定しています)。
| 反復回数 | 振る舞い |
|---|---|
| 1 | 距離は揃うがコライダー押出後の伸びが目視で残る |
| 2 | 改善するが、特定 pose でめり込みが顕在化 |
| 4 | 髪・スカート規模では実用上十分に収束 |
| 8+ | コストに見合う改善が得られない |
これは PBD における Gauss-Seidel 型の反復ソルバ に相当し、布シミュレーション等で標準的に用いられる構造です。厳密解は反復数 $\to \infty$ で得られますが、ゲーム用途では「視覚的に破綻しない程度」に収束させれば十分であるため、4 回で打ち切っています。
3.4 ワールド慣性シフトのサブステップ按分
問題: モデル空間物理はキャラの world 移動に反応しない
本実装の物理計算はモデル空間で進行します。すなわち、各パーティクルの位置はキャラクタを基準とした相対座標で管理されます。この設計は数値安定性の面で大きな利点があります (キャラクタが world のどこに存在しても座標値が変動しないため、浮動小数点誤差の影響が少ない)。
一方で、根本的な副作用があります。
キャラクタが world を移動・回転しても、モデル空間内の粒子位置には何の変化も起きない
例えばキャラクタが world で前進した場合、現実の物理では髪は慣性で取り残されてなびくべきです。しかし、モデル空間内ではキャラ基準で見れば髪は同じ位置のままであり、補正なしでは髪が剛体的にキャラに追従するという不自然な挙動になります。
これを補うため、 world 行列の前フレーム差分から 慣性シフト行列 を構築し、パーティクル位置に適用する機構を導入しています。
慣性シフト行列の定義
前フレームの world 行列を $W_{\mathrm{prev}}$、現フレームを $W_{\mathrm{curr}}$ とします。慣性シフト行列を次式で定義します。
$$
M_{\mathrm{inertia}} = W_{\mathrm{prev}} W_{\mathrm{curr}}^{-1}
$$
この変換をモデル空間のパーティクル位置 $\vec{p}$ に適用した後、現フレームの world 行列でワールド変換すると以下が得られます (本記事はベクトルを行ベクトルとみなし、変換は右からの乗算で表記します)。
$$
\vec{p} M_{\mathrm{inertia}} W_{\mathrm{curr}} = \vec{p} W_{\mathrm{prev}} W_{\mathrm{curr}}^{-1} W_{\mathrm{curr}} = \vec{p} W_{\mathrm{prev}}
$$
すなわち、変換後のパーティクルは 前フレームの world 行列で変換した位置 に置かれます。これは「キャラクタが移動したにもかかわらず、髪は world 上の元の位置に取り残された」状態の数学的表現に他なりません。
具体例で確認すると、キャラクタが world で $+\vec{\Delta}$ 平行移動した場合、 $M_{\mathrm{inertia}}$ は $-\vec{\Delta}$ のモデル空間平行移動として現れ、髪は「キャラ移動と逆方向にずれる」 = 「world 上では取り残される」結果となります。
position と prevPosition の双方に適用する理由
Verlet 積分における速度は $\vec{v}_{n} = \vec{x}_{n} - \vec{x}_{n-1}$ で逆算されます。慣性シフトを position のみに適用すると速度が異常な値に跳ね、結果として粒子が爆発します。
そこで本実装では、 position と prevPosition の 双方に同じ剛体変換 を適用します。
$$
\vec{x}_{n}^{\prime} = M_{\mathrm{inertia}} \vec{x}_{n}
$$
$$
\vec{x}_{n-1}^{\prime} = M_{\mathrm{inertia}} \vec{x}_{n-1}
$$
このとき、変換後の速度は
$$
\vec{v}_{n}^{\prime} = \vec{x}_{n}^{\prime} - \vec{x}_{n-1}^{\prime} = M_{\mathrm{inertia}} (\vec{x}_{n} - \vec{x}_{n-1})
$$
となり、速度ベクトルが剛体変換されるだけで大きさは保たれます。これにより、重力で加速途中の落下速度や前ステップで生じた揺れ速度を失わずに、慣性挙動だけを上乗せできます。
サブステップ按分の必要性
慣性シフトを各フレームの先頭で一括適用する場合、フレームレート依存の不具合が生じます。単純なEuler法での物理処理であればフレーム間の時間を掛ければ済みますが、Verlet法だとそうはいきません。
- 60 fps (1 フレ = 4 サブステップ) の場合: 大きな単発シフト → 4 連続物理ステップ
- 240 fps (1 フレ = 1 サブステップ) の場合: 小さなシフト → 1 物理ステップ
前者では、シフト直後の物理 4 ステップで低 damping ボーンの反発が積み上がり、高周波振動 (痙攣) が観察されます。後者では同等の現象が発生しません。同じ動きが fps によって異なる結果を返すのは望ましくありません。
これを解消するため、慣性シフトを サブステップごとに均等按分 します。サブステップ $s$ ($0 \le s < N$、 $N$ はそのフレームのサブステップ数) における内挿係数を $t_{s} = (s+1)/N$ とし、
$$
W_{s} = \mathrm{lerp}(W_{\mathrm{prev}}, W_{\mathrm{curr}}, t_{s})
$$
$$
M_{s} = W_{s-1} W_{s}^{-1}
$$
を各サブステップで計算してパーティクルに適用します。
Matrix4x4 W_prevSubstep = prevWorld;
for (int s = 0; s < numSteps; ++s) {
const float t = float(s + 1) / float(numSteps);
Matrix4x4 W_thisSubstep = LerpMatrix(prevWorld, worldMatrix, t);
Matrix4x4 substepInertia = W_prevSubstep * W_thisSubstep.Invert();
for (auto& p : m_particles) {
p.position = Vector3::Transform(p.position, substepInertia);
p.prevPosition = Vector3::Transform(p.prevPosition, substepInertia);
}
W_prevSubstep = W_thisSubstep;
// 続けて 1 サブステップ分の物理を進める
IntegrateVerletStep(...);
}
この方式により、フレームレートが変化しても 1 サブステップあたりに与えられる刺激は一定 (小さなシフト + 1 物理ステップ) となり、frame rate independence が成立します。
なお、 world 行列の Lerp は線形補間による微小な歪みを伴いますが、サブステップ間の差分は十分小さく実用上の問題は観察されていません。厳密性が求められる場合は、回転成分を Slerp、並進成分を Lerp で別個に補間する実装に置き換える余地があります。
3.5 円錐角度クランプ
低 stiffness ・低 damping 設定では、揺れが rest pose を大きく逸脱し続ける場合があります。これを抑制するため、各粒子について「親→自パーティクル方向」が rest 方向から maxSwingAngle 度を超えない、という円錐領域の制約を課しています。
$$
\cos \theta = \hat{r}_{\mathrm{rest}} \cdot \hat{r}_{\mathrm{cur}}
$$
$\cos \theta < \cos \theta_{\max}$ の場合、円錐表面へ射影します。
3.6 回転補正
Verlet で更新されるのは粒子位置のみであり、ボーン行列の回転成分は別途補正する必要があります。本実装では、親パーティクルについて「子方向の rest ベクトル → Verlet 結果ベクトル」の最短回転を quaternion で構築し、元の回転に後乗せします。
$$
\hat{a} = \frac{\hat{r}_{\mathrm{rest}} \times \hat{r}_{\mathrm{cur}}}{\left\Vert \hat{r}_{\mathrm{rest}} \times \hat{r}_{\mathrm{cur}} \right\Vert}
$$
$$
\theta = \arccos (\hat{r}_{\mathrm{rest}} \cdot \hat{r}_{\mathrm{cur}})
$$
$$
q_{\Delta} = \mathrm{Quat}(\hat{a}, \theta)
$$
$$
R_{\mathrm{new}} = R_{\mathrm{old}} R(q_{\Delta})
$$
この補正により、メッシュのスキニング結果がボーン主軸方向に正しく追従します。
(…らしいです。というのもquaternionに関しての知識がまだ浅いため正直理解していません。一旦AIに教えてもらったものを使っています。)
追記
理解したので数式をメモとして日本語に起こします。
$\hat{a}$は回転軸であり、つまり粒子位置の更新前と更新後の外積をとり、正規化することで取得しています。またそれら粒子(正規化された位置ベクトル)の内積の$\arccos$はそれらがなす角度にほかなりません。回転軸と角度が分かればquaternionを得ることができるのであとはそれを回転行列に直し、前ステップの回転行列にかけるという操作をしているだけでした。
4. コライダー: Sphere / Capsule の統一表現
体側のボーン (頭・胸・大腿等) に追従させる衝突プリミティブとして Sphere と Capsule をサポートしますが、両者は単一のデータ構造で表現しています。
struct JiggleCollider {
std::string boneName; // 追従先ボーン
Vector3 localOffset; // Sphere 中心 / Capsule 端 A
Vector3 localTipDelta; // 端 B = localOffset + localTipDelta
float radius;
};
localTipDelta の長さによって形状が決定されます。
localTipDelta |
形状 |
|---|---|
| ゼロベクトル | Sphere (端 A と端 B が一致) |
| 非ゼロ | Capsule |
押出計算は、線分 $\vec{AB}$ 上の最近点を求めるアルゴリズム一本で両形状を扱えます。
$$
t = \mathrm{clamp} \left( \frac{(\vec{p} - \vec{A}) \cdot \vec{AB}}{\left\Vert \vec{AB} \right\Vert^{2}}, 0, 1 \right)
$$
$$
\vec{c} = \vec{A} + t \vec{AB}
$$
$\left\Vert \vec{AB} \right\Vert = 0$ の場合は $\vec{c} = \vec{A}$ とすることで、 Sphere もそのまま処理できます。粒子 $\vec{p}$ と最近点 $\vec{c}$ の距離が押出半径 $r$ より小さい場合、粒子を境界へ射影します。
Vector3 ab = c.modelPosB - c.modelPosA;
float abSq = ab.LengthSquared();
Vector3 closest;
if (abSq < 1e-12f) {
closest = c.modelPosA;
} else {
float t = (p.position - c.modelPosA).Dot(ab) / abSq;
t = std::clamp(t, 0.0f, 1.0f);
closest = c.modelPosA + ab * t;
}
Vector3 d = p.position - closest;
float push = (c.radius + p.params.radius) * kColliderScale;
if (d.LengthSquared() < push * push) {
p.position = closest + d.Normalize() * push;
}
データ構造・押出ロジック双方が単一化されているため、保存・読込・UI 編集の経路もすべて一本化されます。
加えて、コライダーごとに 衝突対象から除外する S_ ボーンのリスト を保持できます。これにより、髪用コライダーがスカート (S_kilt_*) に干渉するなどの誤干渉を、エディタ上でボーン名を選択するだけで防止できます。
5. エディタ UI の概要
エディタは Dear ImGui ベースで構築されており、以下のパネル群を提供します。
- ボーン一覧 (S_ ボーンのリスト)
- パラメータ編集 (stiffness / damping / gravity / radius / parentFollow / maxSwingAngle)
- コライダー編集 (形状・オフセット・除外ボーン)
- アニメーション切替 (モデル組込みアニメをクロスフェード)
- シェイプキー編集 (CPU 頂点再合成によるライブプレビュー)
- モデル Transform 編集 (位置・回転・スケール)
UI 上のパラメータ変更は即座に物理ソルバへ反映されます (粒子状態を維持したまま設定のみ差し替え)。これにより、調整と観察のフィードバックサイクルを最短化しています。
3D ビュー上にはパーティクル位置 (球) とチェーン (線分)、各コライダー形状を常時描画し、深度テストを無効化することでメッシュに隠れた部位も視認可能としています。
https://www.youtube.com/watch?v=u6C6U8997Z4
エディターの動作確認動画です
6. 設定ファイル形式 (.jiggle)
設定は JSON 形式で保存します (.jiggle 拡張子)。外部ライブラリ依存を避けるため、必要最小限の JSON サブセット (object / array / string / number / bool / null + 行コメント) を自前で実装しています。
{
"modelPath": "assets/model/Rabiri/rabiri_27.fbx",
"bones": [
{ "name": "S_Hair_R_01",
"stiffness": 0.10, "damping": 0.40,
"gravity": [0, -9.8, 0], "radius": 0.03,
"parentFollow": 0.20, "maxSwingAngle": 45 }
],
"colliders": [
{ "bone": "Head", "offset": [0,0,0], "tip": [0,0,0],
"radius": 0.05, "excludes": ["S_kilt_01"] }
]
}
ファイルパスは FBX のパスから拡張子置換で派生させており (rabiri_27.fbx → rabiri_27.jiggle)、ユーザ側で明示的に管理する必要はありません。
7. おわりに
本記事では、揺れもの物理ソルバ JigglePhysics および専用エディタ JiggleBoneEditor の設計・実装上の要点を述べました。具体的には、
- 命名規則による物理対象の自動判別
- Verlet 積分とモデル空間での物理計算
- 距離拘束とコライダー押出による 4 反復ソルバ
の三点が中心的な設計判断でした。
また、専用エディタを整備したことの副次的な効果として、ゲームプレイ中には気づきにくい微細な不具合 (フレームレート依存の高周波振動など) が顕在化し、結果として物理ソルバ自体の品質改善に寄与しました。エディタは単なる調整環境であるだけでなく、物理実装の検証環境 としても機能していたと言えます。
技術スタック
- C++20 / DirectX 11
- ozz-animation (スケルトンアニメーション)
- Assimp (FBX ローダ)
- Dear ImGui (UI)



