2476個のレンガの城を鉄球で壊す — 剛体ソルバに「崩れ方」を一切教えずに
自作の物理シミュレーション集(132モジュール)の一つ、「解体」モジュールの話です。アニメーションは一枚もありません。レンガ一つひとつが剛体で、接触は毎サブステップ10回のスイープで逐次解かれ、サブステップは毎秒120回。崩れ方は誰も書いていません。
構造物は4種類(壁・円塔・階段ピラミッド・角石を組んだ城)、鉄球の投入口は3方向、球は何個投げても構いません。球は鋳鉄製で、レンガの約8倍の密度です。球について特別なのはそれだけで、あとは同じワールドの普通の剛体なので、球同士の衝突も球とレンガの衝突とまったく同じコードで処理されます。
この記事では、実装中に見つかった3つの「予想外」を共有します。
1. 円塔は弦で積んではいけない
最初の実装は5分で書けました。円周上にレンガの中心を並べ、それぞれをその角度だけ回す。これをソルバに渡す前に、同じ配置を再現して重なりを検査するだけのPythonスクリプト(約150行)を走らせました。
結果:119 mm の食い込み。回転の符号を間違えていて、レンガが接線ではなく半径方向を向いていました。ハリネズミです。
符号を直すと 15 mm。これはもっと厄介です。レンガの内側の面が円周上でちょうど接するように半径を決めると、レンガは直方体で円は曲線なので、内側の角が隣のレンガに食い込みます。実際の石工は外側に楔状の目地を取ってこれを避けます。目地のない私の実装では、半径を内面基準で決め、内側の輪はレンガ厚ではなく「弦の角が触れない距離」だけ内側に寄せる必要がありました。
なぜ致命的か。剛体ソルバは初期状態で貫通している物体を、深さに比例する力で押し離します。塔は立ちも倒れもせず、爆発します。15 mm で十分です。
内側の輪の半径:
r_next = sqrt((r - h_z)^2 - h_x^2) - h_z
(r - h_z は内面半径、h_x はレンガの接線方向の半分の長さ)
2. 城の角石は、目地のずれを無料でくれる
正方形の城壁には、始めるまで気づかない問題があります。角のマスはどちらの壁のものか。常に同じ壁のものにすると、もう一方の壁はただ寄りかかっているだけで、最初の一撃で剥がれます。
石工の答えは交互です。偶数段はX方向の壁が角を持ち、Z方向の壁はその間に収まる。奇数段はその逆。壁同士が指を組むように噛み合います。
意図しなかった副作用:「角を持つ壁」と「持たない壁」のスパン差は 1.5 レンガ。1.5 ということは、隣接段の目地が自動的に半レンガずれます。互い違いの目地を設計していない。角石の幾何がそれを生んでいる。 石工は当然これを知っていました。
3. 順序は美学ではない — 並列化の制約
鉄球が1000個のレンガを起こすと、狭域フェーズは1サブステップあたり約20,000回のOBB-OBB分離軸判定を実行します。1回1〜2μs、1フレームに2サブステップ。これがフレームを食っていました(ソルバ側は接触7000×20スイープで、その数分の一)。
そこで狭域フェーズを並列化しましたが、ソルバは逐次のままです。Gauss-Seidelは怠慢で逐次なのではなく、各接触が隣の接触の力積を読むからです。しかも解く順序は重力軸に沿って上から下でなければなりません。5段の箱の塔で計測した結果:
| 解く順序 | 12秒後の最上段のずれ |
|---|---|
| 下から上(ブロードフェーズの自然な順) | 295 mm |
| 上から下 | 8 mm |
下から上だと、上の重さをまだ支えていない下側の接触で摩擦が μ·Pn に制限され、実際より軽い荷重に対して滑りが許されてしまう。毎スイープ、毎サブステップ。
したがって並列化は「収集 → 処理 → 併合」の3パスにしました:
- 収集(逐次):空間ハッシュを走査し、候補ペアを旧ループと同じ順序で列挙
-
処理(並列):ペア範囲ごとにスレッドローカルのスクラッチへ、
(ペア番号, 連番)を付けて接触を書き出す -
併合(逐次):
(ペア番号, 連番)でソートし直し、既存のAddContactにそのまま流す
これでソルバが受け取る接触列は以前と完全に同じ順序・同じ値になり、上から下のソートもウォームスタートキャッシュも何も気づきません。1スレッドと16スレッドがビット単位で一致します。
正直な差分が一つだけあります。同一パス内で先に起こされた眠っている物体が、そのパス内で二つ目の眠っている物体に出会う経路が消えました。その出会いは次のサブステップ(1/120秒後)に回ります。接触経由で伝播する設計なので、実測可能などの不変量よりも小さい遅延です。
うまくいっていないこと
書き直しの後、「静止したシーンはマネージドメモリを確保しない」という回帰テストが赤くなりました。ログ7本、段階的な切り分けで、1パス・1サブステップあたり640バイトまで絞り込みましたが、まだ犯人は出ていません。書けば直るものではなく、測れば直るものなので、測り続けています。
Windowsは C#/.NET、レンダラはOpenGL、計算バックエンドはCUDA/OpenCL、macOS版はSwift/Metalで移植中です。