物理エンジンの最適化:Collision Layerとビット演算の仕組みをCPU視点で解説 – 物理エンジン原理その2 [Godot, Unity, C#]

August です.

今回のブログ割と短くなると考える.

物理エンジンシリーズその1 を続いて, 今回はレイヤー・マスクとその原理であるビットマスクについて一緒に見てみたい.

余談だが, この記事を書こうと思ったのは, ゲーム会社新卒採用用のプログラミングテストで, Bitシフト演算に関する問題が出てきて,
それに苦労したから, 勉強・復習がてらブログとしてまとめたいと考える.

Layer Maskでできること

原理を説明する前に, まず「なぜこれ知る必要ある?」から説明したい.

一言で言えば,
「無駄な衝突判定をキャンセルして, 処理落ちを防ぐため」

ゲーム内に「弾丸」が100個, 「敵」が100体いるとしよう.
もし何も設定しなければ, コンピュータは馬鹿正直に全ての組み合わせ,「当たったのは敵? それとも他の弾丸?」など, 100(弾) × 100(敵) = 10,000回以上の計算をしようとする.

ここに「背景」や「アイテム」も加わると, 計算量は指数関数的に増え, スマホは熱くなり, フレームレートはガタ落ちするし, そもそも「弾丸どうしで反応するな」って設置したくてもいちいち書かなければならない.

そこで, 「お前はこれとだけ当たればいい, 他は無視しろ」と命令する必要がある. これを管理するのがLayerとMaskの関係である.

  • Layer (レイヤー)
    自分は何者か?」を表す名札 (例:私は「敵チーム」です)
  • Mask (マスク)
    誰と当たりたいか?」を表す招待リスト (例:私は「味方チーム」と「壁」にだけ反応)

物理エンジンは, 衝突計算という重い計算を始める前に, このLayerとMaskを見て「あ, こいつら無関係だわ」と判断したら, 0.0001秒で処理をスキップ(門前払い)する.

この「門前払い」の判定こそが, 今回解説するビット演算そのものである.

さらに, 「なぜゲームエンジンに設定できるレイヤーの上限が, 32なのか」もついでに分かるようになる.

Unity vs Godot: アプローチの違い

物理エンジンの設定画面を見ると, エンジンの設計思想がよく分かる.

特に Unityの「Collision Matrix」Godotの「Collision Layer/Mask」 は, その最たる例の一つだと考える.

1. Unity: 中央集権型 (Collision Matrix)

Unityユーザーにはおなじみの, 巨大なマトリックス表.

これは「世界全体のルール」を1箇所で管理するアプローチ. 安全で分かりやすい? が, 1つの大きな制約がある

1つのGameObjectは, 1つのLayerにしか所属できない.

つまり, 「水 (Water)」であり「毒 (Poison)」でもある沼を作りたい場合, Unityでは WaterAndPoison という新しいレイヤーを作るか, タグで判定するなどの工夫が必要になる.

「あれとこれが当たる」という関係性をマトリックスで一元管理するため, 複雑な属性を持つオブジェクトが増えると, マトリックスの管理が指数関数的に面倒になることがある.

2. Godot: 分散型 (Layer & Mask)

対してGodotは, 各オブジェクトが個別に自分は何者か (Layer)」「何と当たるか (Mask)」をビット単位で保持している.
これは物理エンジンの内部ロジック (ビット演算) をそのままユーザーに露出させている形.

メリット:

1つのオブジェクトが複数のレイヤーに同時に所属できる.

例えば, 弾丸に Projectile, LightSource, Magic の3つのビットを立てておけば, それぞれに対応するマスクを持つ壁や敵と適切に衝突する.

デメリット?:
個人的にこれをデメリットとして見ないが,
要は, 物体一個一個に対応するマスクを設定しないといけない. (或いは共通する設定を保存して呼び出す)

「直感的」なのはUnityの表(本当か?)だが, 「機械的・論理的」なのはGodotの方式だと考える.

どちらも最終的には同じ計算(ビット演算)に帰結する.

Layer Maskの原理:ビットマスク

ビットって何? って思った人はまずそれに関して検索した方がいい. 僕より遥かに説明が上手い人らが解説してくれる.

簡単に言うと,
我々が使う通常計算機において, 全てのデータは, 二進数[0,1]で表現される.
これの単位が, ビットである.

つまり, 1bit = 0 or 1 = True or False

通常変数(Primitives)の根本的な表現

情報系なら常識同然な知識だが, int, floatなどのprimitive型は, それぞれ所要するビットサイズも異なる.
C#において, intfloatは, 4byte = 32 bitかかる.

bool型は, 本来1bitで表現できる. が, これは本当はある意味意味を持たない. 

なぜなら, CPUは「ビット単位でメモリにアクセスできない」から.

CPUが一度に掴める最小単位は「バイト (8bit)」(実際に, 一般的に64BitのCPUが一度のフェッチで掴む量は, 64Bit = 8 Byte.)

つまり, たった1bitの true/false を表現するために, プログラム上では贅沢にも8bit分(1バイト)の領域を確保してしまう.

これをn個のフラグ(状態異常, レイヤー, アイテム判定など)でやろうとすると, n回のメモリアクセスと無駄な領域が必要になる.

解決策:ビットマスク

Bitmask(ビットマスク)は, まさにこれを解決するためにある.

bool をバラバラに持つのではなく, 「1つの int (32bit) の中に, 32個のスイッチを詰め込んでしまおう」 という発想.

これなら, たった1回の処理で32個分までの判定を一気に扱える.
なぜゲームエンジンに設定できるレイヤーの上限が, 32なのか」=> この32は, これが原因である.

でもこれどうやって管理する?
ここで「ビット演算」が出てくる.

怖くないビット演算

「うわ, 計算だ」とブラウザバックしないでほしい.
実はやっていることは, 「ID決め」と「合成」と「確認」, 怖いのは記号だけ.

以下の5つのレイヤーがあるとして, どうやってスイッチを管理するか見ていこう.
[ 地形, 味方, 味方の弾, 敵, 敵の弾]

1. シフト演算 (<<):IDを決める

各レイヤーに「会員番号」を振る作業.
1 << n と書けば, 「1を左へ n個 ズラす」という意味になる. これがそのままレイヤーのIDになる.

// 定義
// 一番右(0番目)から順にスイッチを割り振るイメージ

int LAYER_地形        = 1 << 0; // ...00001 (1)
int LAYER_味方        = 1 << 1; // ...00010 (2)
int LAYER_味方_PROJ   = 1 << 2; // ...00100 (4)
int LAYER_敵          = 1 << 3; // ...01000 (8)
int LAYER_敵_PROJ     = 1 << 4; // ...10000 (16)

2. OR演算 (|):マスクを作る(合成)

複数のレイヤーを対象にしたい場合,
足し算…ではなく OR演算(|) を使う.

ビットマスクにおいては「合成(リストアップ)」と考えていい.

例:味方の飛び道具 (LAYER_味方_PROJ) の当たり判定を作ってみよう.
仕様:「地形」と「敵」の両方に当たる.

// マスクの作成 (地形 と 敵 をくっつける)
int hitMask = LAYER_地形 | LAYER_敵;

// 計算の中身:
//   00001 (地形: 1)
// | 01000 (敵: 8)
// --------
//   01001 (Mask値: 9)

これで「地形と敵に当たる」というリストができた.

3. AND演算 (&):衝突判定(フィルター)

これが物理エンジンの内部で行われている判定の正体.
「自分のマスク(リスト)」の中に「相手のレイヤー」が含まれているか?を確認する作業.

AND演算がTrueになるのは, 両方とも1である場合のみ.

さっき作ったマスク(01001 = 地形と敵)を持つ弾が, 何かにぶつかったとしよう.

ケースA:味方(LAYER_味方 = 2)に当たった場合

味方レイヤーは ...00010 (2)
判定式: (Mask & Target)

  01001 (弾のMask)
& 00010 (味方のLayer)
-------
  00000 (結果はゼロ = false)

結論:共通点がないので衝突しない(すり抜ける)

ケースB:敵(LAYER_敵 = 8)に当たった場合

敵レイヤーは ...01000 (8)

  01001 (弾のMask)
& 01000 (敵のLayer)
-------
  01000 (結果はゼロではない = true)

結論:衝突検知! -> ダメージ処理へ

C# (Unity / Godot) での実装例

UnityやGodotのインスペクターでポチポチ設定しているのは, 裏でこの計算を自動化してくれているに過ぎない.

スクリプトで動的に「今は味方だけ狙いたい!」といった制御をする場合, 以下のように書ける.
※GodotのC#でもUnityでも, 基本的な書き方は全く同じ.

[System.Flags] // これを付けるとInspectorで複数選択できるようになる
public enum TargetLayer {
    None       = 0,
    Terrain    = 1 << 0, // 地形
    Ally       = 1 << 1, // 味方
    AllyProj   = 1 << 2, // 味方の弾
    Enemy      = 1 << 3, // 敵
    EnemyProj  = 1 << 4, // 敵の弾
}

void CheckCollision(TargetLayer target) {
    // 自分の攻撃対象マスク (地形 + 敵)
    TargetLayer myMask = TargetLayer.Terrain | TargetLayer.Enemy;

    // ビット演算で判定
    // (0ではない = マスクに含まれている)
    if ((myMask & target) != 0) {
        // Hit!
        // ダメージ処理などをここに書く
    }
}

物理判定以外での活用例

ビットマスクとか物理エンジンの中だけの話でしょ?」と思うかもしれないが,
「複数の状態を同時に管理する」場面ならどこでも使える.

知っておくと便利な2つのパターンを紹介する.

1. RPGの状態異常 (バフ・デバフ)

毒, 麻痺, 沈黙… これらを bool isPoison, bool isSleep と管理するのはコードが汚れる上に, Inspectorでの管理も面倒だ.

[System.Flags] をつけた enum なら, 1つの変数で管理でき, エンジンのInspector上でチェックボックスとして編集できるようになる.

[System.Flags]
public enum StatusEffects {
    None     = 0,
    Poison   = 1 << 0,
    Silence  = 1 << 1,
    Paralyse = 1 << 2,
}

// プレイヤーの状態
StatusEffects currentStatus = StatusEffects.Poison | StatusEffects.Paralyse;

// 判定:行動可能か? (HasFlagは便利だが, ビット演算の方が高速)
if ((currentStatus & StatusEffects.Paralyse) != 0) {
    return; // 動けない
}

// 治療:毒だけ治す (反転させてAND = 毒だけ消す)
currentStatus &= ~StatusEffects.Poison;

まぁ…強いていえば, これは
ビット演算符号が熟知していない人にとって, 非常に読みにくいではある.

2. オートタイル (地形の自動接続)

2Dゲームで壁を並べた時, 自動的に「角」や「T字路」の画像に切り替わるアレ.

実はこれもビットマスクを使うと, 驚くほど単純な足し算で実装できる.

自分の「上・右・下・左」に壁があるかをチェックし, それぞれにIDを振る.

  • 上がある = 1
  • 右がある = 2
  • 下がある = 4
  • 左がある = 8

これらを合計すると, 0〜15のユニークな数字が生まれる.
例えば「上(1)と右(2)」に壁があれば 1+2=3.

あとは tile_3.png という名前の「右上の角画像」を用意して読み込むだけ. 複雑な if 分岐は一切いらない.

結論

Layer MaskやCollision Matrixの画像(記事冒頭)を見直してみてほしい.
あれはただの表に見えるが, エンジン内部では巨大な int 同士の AND 演算を高速で行っているだけなのだ.

  • Layer = 自分のID (1bitだけ立っている)
  • Mask = 当たりたい相手リスト (複数のbitが立っている)
  • Layer & Mask != 0 = 衝突

常に2個以上bool変数を管理・比較する場合, if (isPoisoned || isParalyzed || isSleep) と書くよりも, ビットマスクを使ったほうがCPUにも優しく, コードも拡張性が高い.

食わず嫌いせず, シフト(<<)とOR(|)とAND(&)を使いこなしてみよう.

ではまた.

コメントを残す

CAPTCHA