Unity で暴れる Joint を鎮めるテクニック

最近 Unity で暴れるジョイントを鎮めました。意外と一般的に役立つテクニックではないかと思ったので、原理とともに解説しておきます。

2022-10-03 06:27:40 MondayEnglish version is here.

チェーンやロープのようなものを Joint と Rigidbody で作ろうとして上図のようになった方、結構いるんじゃないでしょうか。自分もなりました。

こういう場合に使える、あまり知られていなそうな解決策があります。

実際に暴れさせてみる

Unity を起動し、図のようなシーンを作成しました。

灰色のキューブは Kinematic、黄色の球と赤いキューブは Dynamic に設定してあります。また、各剛体の接点に Character Joint をセットしてチェーンを作ってあります。さらに、黄色の球の質量をそれぞれ 1.0、赤のキューブの質量を 10.0 に設定しました。

再生して赤いキューブを少し横に引っ張ると……

暴れ出しました。

そもそもなぜ暴れるか

物理エンジンが弱いからです。

・・・

と言ってしまうと元も子もないのですが、実は、この手の問題には物理エンジン(とりわけリアルタイム計算用のもの)は一般的に弱いです。Unity で使われている PhysX もその例外ではありません。

鎖で何かを繋ぐ場合、その両端には鎖自身よりもはるかに重いものが繋がっていることが多いです。つまり、

- 軽 - 軽 - … - 軽 -

という順で繋がることが多いです。慣れてくると直感的に分かるようになるのですが、この並びは物理エンジンとの相性が最悪[1]です。Unity の公式マニュアルでも、そのような設定を避けるよう推奨しています。

ジョイントで接続される Rigidbody コンポーネント間の質量に、極端な差を作らないようにします。ある Rigidbody の質量が他方の質量の 2 倍程度の差であれば問題ありませんが、質量に 10 倍もの差がある場合はシミュレーションは不安定になります。

実はこの説明はやや不正確で、ジョイントの中間に極端に軽い物体が存在しない場合はシミュレーションは安定します。例えば、Kinematic に接続されたジョイントの質量比は ∞ ですが、それ単体では問題を引き起こしません。

なぜ重いものに挟まれると不安定になるか、本質を外さないようにしつつ簡略化して説明すると次のようになります。

軽い物体を挟むと不安定化するメカニズム

物体 1, 2, 3 が一列に並んでおり、物体 1 と 2 を A さんが、物体 2 と 3 を B さんが繋いでいると考えてください。A さんと B さんの仕事は、繋いでいる物体の距離を一定に保つことです。ただし、物体 2 は物体 1, 3 に比べて非常に軽いものとします。

[1]---A---[2]---B---[3]

いま、物体 1 と 3 が左右にものすごい引っ張られたとします。

[1]--------A--------[2]--------B--------[3]

伸びてしまいました。伸びてしまったので、A さんと B さんはそれぞれ物体をたぐり寄せ、長さを元に戻そうとします。

まず A さんは、伸びてしまった距離から縮めるべき距離を計算し、必要な分だけ物体 1 と 2 をそれぞれ引き寄せます。ニュートンの法則より、A さんは物体 1 と 2 を同じ大きさの力で引き寄せます。しかし、物体 1 は物体 2 に比べて非常に重いので、ほとんど動きません

結果として、物体 2 が大幅に左に動き、物体 1 は僅かに右に動きます。

[1]---A---[2]----------------B--------[3]

一応は元の長さに戻り、A さんは満足しました。

しかし B さんにとってはどうでしょう。物体 2 が大きく左に引っ張られたせいで、さらに状況が悪化してしまっています

続いて B さんも同じように物体 2 と 3 をたぐり寄せますが、物体 3 は物体 2 に比べて非常に重いため、やはりほとんど動きません

[1]---A-------------------[2]---B---[3]

同じく一応は元の長さに戻った B さんは満足します。

しかし A さんはまた大きく伸ばされてしまいました。こうして A さんと B さんは軽い物体である物体 2 の過剰な綱引きを繰り返します

[1]---A-------------------[2]---B---[3]
  [1]---A----[2]----------------B---[3]
  [1]---A---------------[2]---B---[3]
    [1]---A----[2]------------B---[3]
    [1]---A-----------[2]---B---[3]
      [1]---A----[2]--------B---[3]
      [1]---A-------[2]---B---[3]
        [1]---A----[2]----B---[3]
        [1]---A---[2]---B---[3]

ようやく二人とも元の長さに戻り、落ち着くことができました。

しかし、もしも物体 1, 2, 3 が同じくらいの重さだったらどうなっていたでしょう? 引き寄せる距離が両物体で同程度になるので、

[1]--------A--------[2]--------B--------[3]
     [1]---A---[2]-------------B--------[3]
     [1]---A----------[2]---B---[3]
         [1]---A---[2]------B---[3]
         [1]---A----[2]---B---[3]
          [1]---A---[2]---B---[3]

このように、より少ない回数で元の長さに戻れていたはずです。

本質的にはこれと全く同じ現象が物理エンジンの内部で起こっており、ジョイントが意図しない挙動をする原因の一つとなっています。

質量比を下げてみる

ぶら下がっている物体が鎖本体よりも重いのが原因と思われるので、赤のキューブの質量を 10.0 から 1.0 に下げてみます。

暴れなくなりました。

しかし、球体とキューブの質量が同じになってしまったため、キューブが鎖で繋がれているというよりは、鎖の先になんかキューブが付いてるみたいな印象になってしまいました。

さらに、キューブをやや強く引っ張ってみると……

再び暴れ出しました。どうやら質量比以外にも暴れる原因があるようです。

回転運動の非線形性

実は、ジョイントが暴れる原因の多くは質量比ではなく回転運動にあります。

質量比が大きい場合、拘束を解ききれずにジョイントが伸びる原因にはなりますが、今回のようにジョイントが大きく暴れている場合は回転運動が原因である可能性が高いです。

これも物理エンジンの仕組みが深く関わっています。

物理エンジンのソルバと線形方程式

やや難しい話になります。PhysX, Box2D, Bullet Physics 等々の剛体物理エンジン[2]においては、各剛体の接触点やジョイントにおいてかかる力を計算するために、内部で線形方程式(1次方程式)を解いています

本当は非線形なコーン型摩擦の計算や接触点の線形相補性問題を解く必要がありますが、線形方程式を反復して解くための Gauss-Seidel 法に毛が生えた程度のソルバ (Projected Gauss-Seidel) で解けるので、本質的には線形方程式と思ってもらって問題ありません。

さて、剛体の等速並進運動は線形です。位置 \bm x にあった剛体が速度 \bm v で並進している場合、時間 t だけ後の剛体の位置は正確に \bm x+t\bm v と予測できます。そのため、速度から位置変化の予測を正確にソルバに組み込むことができます。

一方、剛体の等速回転運動は非線形[3]です。話を簡単にするために2次元で考えると、原点で角速度 \omega で回転する剛体上の一点 \bm r\ (\neq\bm 0) は、直線ではなく円を描きます

したがって、時間 t だけ後の点の座標を表すためには、次の非線形な表示が必要になります。

\begin{pmatrix}
    \cos(t\omega) & -\sin(t\omega) \\
    \sin(t\omega) & \cos(t\omega)
\end{pmatrix} \bm r

しかし、これをまともに線形方程式に組み込むことは不可能です。よって、次のような近似をします:

「本当は回転運動をしているが、ごくわずかな時間だけ切り取って運動を見てみるとほとんど並進運動に見えるため、その並進運動をもって回転運動の近似とする」

まあただの微分です。黒球の真の軌跡(赤矢印)を、線形な軌跡(青矢印)で近似します。見て分かる通り、最初のうちは良い近似になっているものの、時間が経つにつれ真の軌跡との乖離が激しくなってきます

実際の物理エンジンでは、多くの場合 1/60 秒の間隔で剛体の座標更新を行います。したがって、この 1/60 秒の間に剛体が大きく回転する場合、回転運動の近似精度が悪化し、線形ソルバが正しく次の位置を予測できない状態に陥ります。

するとどうなるか?

ジョイントが暴れ出します

反復回数の増加が効かない

しかも厄介なことに、この問題はソルバの反復回数を上げることでは解決できません

質量比が大きい場合は、単に収束が遅いだけなのでソルバの反復回数を増やすことで収束精度を高める効果があるのですが、今回の場合、そもそもソルバが状況を正しく認識できていないため、いくら反復回数を上げてソルバの出力精度を上げたところで効果がないのです。

実際に試してみましょう。

Unity の Project Settings を開き、Physics から Default Solver Iterations を 100 にします。この数値は普通のゲーム用途では重すぎて使い物にならないくらいの extreme な設定です。通常ジョイントが伸び切ってしまうようなとんでもない質量差にも耐えます

再生してキューブを引っ張ってみます。

ソルバの必死の抵抗もむなしく、あっけなく暴れ出します。ちなみにキューブの質量はさっき 1.0 に戻したので質量比は 1 です。どれだけ回転運動が恐ろしいかが分かりますね。

追記:ソルバの反復回数を上げるのは効果がありませんが、時間刻み幅を小さくするのは近似の精度が上がるため非常に有効です。欠点としては、整数倍で分割しないとゲームの進行速度と物理演算の進行速度を合わせる処理が必要になって面倒なことと、必要な計算の分量がダイレクトに反比例で増加することあたりでしょうか。

余談: Box2D は偉い

実は先ほど挙げた物理エンジンの中で、Box2D は桁外れにジョイントが強靭です。これは、Box2D が通常のソルバに加え回転運動に対応した非線形なソルバを追加で走らせているからです。興味のある方は Nonlinear Gauss-Seidel で調べてみてください[4]

似たような効果を持つ機能として、拘束が解ききれなかった際に位置だけでも戻そうとする Joint Projection という機能が Unity にも用意されていますが、こちらは焼け石に水で、暴れるチェーンを鎮める力はないと思っていいでしょう。それでもラグドールの腕や首が伸びるのを防ぐ目的では十分だと思います。まあ見た目をどうにか取り繕う程度です。

また、Bullet Physics には Featherstone’s Algorithm が実装されており、有効にするとこの手のチェーンを完全に解ききってくれます。そもそも反復手法に頼らないアルゴリズムなので、一切チェーンが伸びないという強力なメリットはあるものの、直接解法ゆえの不安定性が出現することがあるのと、ジョイントの接続に閉路が存在するとアルゴリズムを使用できないというのが玉に瑕です。このアルゴリズムは難解で、実は筆者もよくまだ理解できていません。そのうち理解して実装してみたいですね。

暴れるジョイントを鎮める方法

さて本題です。暴れるジョイントの原因が回転運動にあるなら、剛体を回転しにくくしてやればいいのです。鍵は慣性モーメント[5]にあります。

慣性モーメントを増やす

重い物体が動かしにくいというのは誰でも理解できると思います。これは言い換えると「質量が大きい物体は並進速度を変化させづらい」ということになります。この並進速度を角速度に置き換えたときに、質量にあたるものが慣性モーメントになります。つまり慣性モーメントは物体の回転のしにくさを定義します

C# が使える場合、スクリプトから適当にチェーン部分の剛体の慣性モーメントを増やしてやりましょう。RigidBody.inertiaTensor からアクセスできるようです。あまり増やしすぎると不自然に見えるので、ジョイントが暴れ出さない程度の値にとどめておきましょう。

訳あって C# にアクセスできない人もいるかと思いますので、Unity Editor から慣性モーメントを増やす方法を紹介しておきます。

慣性モーメントを Unity Editor から増やす

慣性モーメントの情報は Inspector の Rigidbody → Info から見ることができます(Inertia Tensor の値がそれです)。

ここから触れればよかったんですが、残念ながら灰色になっていて触れません。残念。

どうやらこの値は剛体内の Collider によって計算されるようで、Collider に細工をすることでこの値を変化させられそうです。

慣性モーメントの性質

突然ですが、100 g の鉄球と 100 g の発泡スチロール球、どちらが重いでしょう?

当然ながら同じ質量なので重さは同じです。では、どちらの方が慣性モーメントが大きいでしょう?

実は、発泡スチロール球の方が圧倒的に慣性モーメントが大きいのです。これは、物体の質量が広範囲にわたって分布している(=重心から離れた場所に多くの質量が分布している)ことに由来します。

実際は発泡スチロール球の方が半径が大きいので、手で持って回す労力にあまり差は感じられないかもしれません。しかし、てこの原理と同様に重心から離れるほど生じるトルクが大きくなるため、鉄球の半径と同じ位置に指を突っ込んで回そうとすると何倍もの労力が必要になります。

コライダーを細工する

以上を踏まえ、Collider から計算される慣性モーメントの値を大きくするにはどうすればよいでしょうか。

答えは簡単で、Collider を大きくすればよいのです。

思い切って Sphere Collider の半径を 5 倍にしてみました。慣性モーメントの値を見てみましょう。

質量こそ同じですが、慣性モーメントが実に 25 倍になっています。一般に、総質量と密度分布の比率を一定に保ったまま物体の大きさを r 倍にすると、慣性モーメントは r^2 倍になります。

しかし、このままでは本当に球が大きくなっただけなので、レイヤー機能を活用して実際に使われる Collider と質量計算に使われる Collider を分離します。

Project Settings → Tags and Layers から虚無用のレイヤー(Ghost という名前にしました)を追加し、Physics タブにある Layer Collision Matrix から虚無レイヤーの列のチェックを全部外します。

これで Ghost に追加された Collider は機能しなくなります。

注意点として、衝突しなければいいやと思って Collider の Is Trigger にチェックを入れてしまうと質量計算の対象からも外れてしまうので、必ず Is Trigger のチェックは外しておきましょう。

続いてチェーンに使われているボール本体のレイヤを Ghost に設定して Collider を大きくし、

子オブジェクトとして本来の大きさの Collider だけを持った GameObject を Default レイヤーで生やします。

これで質量計算の際には両方の Collider が考慮され、衝突計算には本来の Collider のみが使用されるようになります。

動きを確認する

さあ、チェーンを動かしてみましょう。

先程まで少し強めに引っ張ると暴れ散らしていたのが嘘のようで、思いっきり引っ張ってもすぐに戻ってきて安定します

赤いキューブの質量を 1.0 から 10.0 にしてみます。

多少伸びやすくなったものの、動きは安定しています。この伸びやすさは質量比由来のものなので、ソルバの反復回数を上げることで伸びにくくできます。

試しに反復回数を 6 から 20 にしてみましょう。

ほとんど伸びなくなりました。ソルバが本来の性能を発揮できていることが分かりますね。ソルバの処理にかかる時間だけは伸びていることに注意してください。

おまけ: 補助拘束を追加して伸びにくくしよう

今回のようなケースでは、ジョイントを伸びにくくするために補助拘束を導入することが有効です。

例えば、初期状態におけるキューブの接続位置とチェーンの根本との距離が l だったとします。このとき、チェーンが伸びないと仮定すればキューブの接続位置とチェーンの根本との距離は決して l を上回らないはずです。そこで、チェーンの根本とキューブの接続位置を直接最大距離を l に制限するジョイントで繋いでやります。こうすることでチェーン本体にかかる負担が大幅に減り、ほとんどチェーンは伸びなくなります[6]

Box2D にはこのための専用ジョイントである Rope Joint が用意されているのですが、残念ながら Unity には Rope Joint はおろか Distance Joint すら存在しないので、実現は難しそうです。Spring Joint を活用すれば似たようなことはできるかもしれません。

この補助拘束の概念をさらに深掘りすると、究極的には Multigrid 法という手法に行き着きますが、その話はまた機会があれば。


  1. より一般には、反復解法を用いる数値計算手法と相性が悪いです。連立方程式の係数行列の条件数が関連しています。 ↩︎

  2. 筆者の開発した OimoPhysics も含まれます ↩︎

  3. 実際は角速度ではなく角運動量が保存するため、一般に同じ角速度で回転運動を続けることはないのですが、計算が難しいため多くの物理エンジンは角運動量の代わりに角速度を保存させています。ここでも角速度が固定されているものとして話を進めます ↩︎

  4. ちなみに OimoPhysics にも Nonlinear Gauss-Seidel を走らせるオプションがあります。この辺りは完全に Box2D リスペクトです ↩︎

  5. 慣性モーメントの正体は2階のテンソルなので、慣性テンソルとも呼ばれます。ここでは慣性モーメントで通すことにします ↩︎

  6. oimo.io の Works ページにこの技術が適用されています。 ↩︎

このエントリーをはてなブックマークに追加