【ゲーム制作】レイヤーベースの衝突判定とは?|ビットマスクで「誰と当たるか」を制御する

collision layer thumbnail ゲーム制作

「プレイヤーの攻撃が、プレイヤー自身に当たらないようにするにはどうすればいい?」「弾と弾同士は無視したいけど、プレイヤーには当たってほしい…この『誰と誰が当たるか』ってどう管理するの?」「ビットマスクって聞くけど難しそう」

こんな疑問はありませんか?

今回は、当たり判定システムの土台になるレイヤーベースの衝突判定について、実際にコンパイル・実行した結果を交えながら解説していきます。実装中に実際に踏んだ、地味だけど厄介なバグも紹介します。

レイヤーベースの衝突判定とは?

レイヤーベースの衝突判定とは、

・すべてのオブジェクトに「レイヤー」という所属を与え、レイヤーの組み合わせごとに「当たるかどうか」を先にルールとして決めておく仕組み

です。「プレイヤーの攻撃」と「敵」は当たってほしいけれど、「プレイヤーの攻撃」と「プレイヤー自身」は当たってほしくない、というようなルールを、当たり判定のコードの中にif文で埋め込むのではなく、事前に1箇所の表として管理できるのが利点です。

ビットマスクで実装してみる

各レイヤーに1つずつビットを割り当て、「このレイヤーはどのレイヤーと衝突するか」を32bit整数のビットマスクとして持たせます。

enum class CollisionLayer : uint32_t {
    Player = 0,
    PlayerAttack,
    Enemy,
    EnemyBullet,
    Terrain,
    Count
};

uint32_t LayerBit(CollisionLayer layer) {
    return 1u << (uint32_t)layer;
}

// レイヤーごとの「衝突相手マスク」を持つ表
uint32_t g_CollisionMask[(int)CollisionLayer::Count] = {};

void SetCollision(CollisionLayer a, CollisionLayer b, bool enable) {
    if (enable) {
        g_CollisionMask[(int)a] |= LayerBit(b);
        g_CollisionMask[(int)b] |= LayerBit(a); // 対称に設定する
    } else {
        g_CollisionMask[(int)a] &= ~LayerBit(b);
        g_CollisionMask[(int)b] &= ~LayerBit(a);
    }
}

bool ShouldCollide(CollisionLayer a, CollisionLayer b) {
    return (g_CollisionMask[(int)a] & LayerBit(b)) != 0;
}

次のようなルールを設定して、実際に組み合わせを試してみます。

SetCollision(CollisionLayer::Player, CollisionLayer::Enemy, true);       // 体当たりで当たる
SetCollision(CollisionLayer::Player, CollisionLayer::Terrain, true);     // 地形に当たる
SetCollision(CollisionLayer::PlayerAttack, CollisionLayer::Enemy, true); // 攻撃は敵に当たる
SetCollision(CollisionLayer::EnemyBullet, CollisionLayer::Player, true); // 敵弾はプレイヤーに当たる
SetCollision(CollisionLayer::EnemyBullet, CollisionLayer::Terrain, true);// 敵弾は地形に当たる
// 意図的に設定しないもの:
//   PlayerAttack と Player      -> 自分の攻撃が自分に当たらない
//   EnemyBullet と PlayerAttack -> 弾と攻撃判定同士は無視
//   PlayerAttack と Terrain     -> 攻撃判定は地形をすり抜ける

実行結果です。

LayerA         vs LayerB         : ShouldCollide
--------------------------------------------------
Player         vs Enemy          : true
Player         vs Terrain        : true
PlayerAttack   vs Enemy          : true
PlayerAttack   vs Player         : false
EnemyBullet    vs Player         : true
EnemyBullet    vs PlayerAttack   : false
PlayerAttack   vs Terrain        : false

設定した通りの組み合わせだけがtrueになり、「自分の攻撃が自分に当たる」「攻撃判定同士がぶつかる」といった不要な組み合わせはすべてfalseのままです。当たり判定処理側はif (ShouldCollide(a.Layer, b.Layer))と1行書くだけで済み、レイヤーの組み合わせルールを増やしたいときもこの表を書き換えるだけになります。

【重要】実際に踏んだ罠:片方向にしか設定していなかった

このデモを作る前、最初に書いたバージョンではSetCollisionの中で片方向にしかビットを立てていませんでした。

// バグ版: 片方向にしか設定していない
void SetCollisionNG(CollisionLayer a, CollisionLayer b) {
    g_CollisionMask[(int)a] |= LayerBit(b); // aから見てbは衝突対象、にしかしていない
}

SetCollisionNG(Player, Enemy)を呼んだ後、Player視点とEnemy視点それぞれで判定してみます。

ShouldCollide(Player, Enemy) = true
ShouldCollide(Enemy, Player) = false

同じ2つのレイヤーの組み合わせなのに、聞く順番を変えただけで結果が変わってしまいました。これは、衝突判定処理を「オブジェクトAから見てB」の順で書くか「オブジェクトBから見てA」の順で書くかによって、当たったり当たらなかったりする実際に見つけると原因が分かりづらいバグになります。ShouldCollide関数自体は正しく動いているのに、設定する側で片方向しか登録していなかったのが原因でした。対処法はシンプルで、SetCollisionの中で必ず両方向に登録することです(最初のコード例の通りです)。

レイヤーベース衝突判定のよくある失敗例と対処法

①片方向にしか設定しない

上で実演した通りです。ShouldCollide(a, b)ShouldCollide(b, a)常に同じ結果になるべきだと決めておき、設定関数の中で両方向を必ず更新しましょう。

②レイヤー数が32を超える設計にしてしまう

uint32_tのビットマスクは最大32レイヤーまでです。それ以上必要になったらuint64_tに変えるか、本当にそんなに多くのレイヤーが必要か設計を見直しましょう。

③レイヤーとタグ(種別)を混同する

レイヤーは「誰と衝突判定するか」を決めるためのものです。「これは回復アイテムだ」「これはボスだ」のような、衝突判定に関係ない分類まで全部レイヤーに詰め込むと、あっという間に32個の枠を使い切ってしまいます。衝突判定に関係ない分類は別のタグ機構で管理しましょう。

注意点

  • 設定関数は必ず両方向を更新する
  • レイヤーは最大32個(uint32_tの場合)
  • 衝突判定に関係ない分類はレイヤーではなく別のタグで管理する

まとめ

  • レイヤーベースの衝突判定は「誰と誰が当たるか」を事前に表で管理する仕組み
  • ビットマスクのAND演算1つで判定できる
  • 片方向にしか設定しないと、聞く順番で結果が変わるバグになる(実際に踏んだ)
  • 対処法は、設定関数の中で必ず両方向を更新するだけ

「AとBが当たるかどうか」が聞く順番で変わるというのは、実際に自分で再現してみるまで盲点でした。対称性を意識するだけで防げる罠なので、実装するときはぜひ気をつけてみてください。

ここまで読んでくださりありがとうございました。

関連記事