【ゲーム制作】当たり判定の最適化|四分木(Quadtree)をC++で実装する

ゲーム制作

今回は、ゲーム制作でよく出てくる「四分木(Quadtree)による当たり判定の最適化」について解説していきます。

「敵を増やしたら急に重くなった…」
「当たり判定の総当たりって、どうにかならないの?」
「四分木って聞くけど、なんだか難しそう…」

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

当たり判定は、オブジェクトが増えると急激に重くなる処理の代表です。100個で耐えられても、500個で一気に破綻します。四分木は、その「総当たり」をやめるための空間分割という考え方です。

この記事を読み終えると、あなたはO(n²)が破綻する理由・四分木の考え方・C++での実装・初心者がハマりやすいミスをしっかり理解できると思いますので、ぜひ最後まで読んでいただけると嬉しいです。なお当たり判定そのものはAABBの当たり判定の記事で解説しているので、先にそちらを読むとスムーズです。

なぜ総当たりだと破綻するのか

まず、素直に書いた当たり判定を見てください。

// 総当たり:全部の組み合わせを調べる
for (size_t i = 0; i < objects.size(); ++i) {
    for (size_t j = i + 1; j < objects.size(); ++j) {
        if (Hit(objects[i], objects[j])) {
            // 当たった時の処理
        }
    }
}

これは分かりやすいのですが、組み合わせの数が問題です。オブジェクトが n 個なら、判定回数はおよそ n×(n-1)/2 回。これが O(n²) と呼ばれる状態です。

オブジェクト数判定回数(約)
10個45回
100個4,950回
500個124,750回
1,000個499,500回

オブジェクトが10倍になると、判定回数は約100倍です。しかもこれを毎フレームやります。60FPSなら1秒間に60回。1,000個で毎秒3,000万回の判定になり、当然フレームが落ちます。

でも、よく考えてください。画面の左端の敵と右端の敵が当たるはずがありません。総当たりは、その「明らかに当たらない組み合わせ」まで全部律儀に調べています。ここが無駄なのです。

四分木(Quadtree)とは?

四分木とは、空間を4つに区切っていき、「近くにいる相手だけ」を調べられるようにするデータ構造です。

考え方はシンプルです。

  1. 画面全体を1つの区画とする
  2. その区画にオブジェクトが増えすぎたら、4つ(左上・右上・左下・右下)に分割する
  3. 分割した先でもまた増えすぎたら、さらに4分割する
  4. 判定するときは、自分と同じ区画にいる相手だけを調べる

「木」という名前は、1つの区画が4つの子を持ち、その子がまた4つの子を持つ…という枝分かれの形から来ています。

四分木をC++で実装する

順番に作っていきます。まずは矩形オブジェクトの型を用意します。

#include <vector>
#include <memory>

// 矩形(中心x, 中心y, 半分の幅, 半分の高さ)
struct Rect {
    float cx, cy, hw, hh;

    // 点を含むか
    bool Contains(float x, float y) const {
        return x >= cx - hw && x <= cx + hw &&
               y >= cy - hh && y <= cy + hh;
    }

    // 他の矩形と重なるか(AABB判定)
    bool Intersects(const Rect& o) const {
        return !(o.cx - o.hw > cx + hw || o.cx + o.hw < cx - hw ||
                 o.cy - o.hh > cy + hh || o.cy + o.hh < cy - hh);
    }
};

struct GameObject {
    float x, y;
    int   id;
};

次が本体です。容量を超えたら4分割するのがポイントです。

class Quadtree {
public:
    Quadtree(const Rect& boundary, int capacity = 4)
        : boundary_(boundary), capacity_(capacity), divided_(false) {}

    // オブジェクトを追加する
    bool Insert(const GameObject& obj) {
        // 自分の範囲外なら受け取らない
        if (!boundary_.Contains(obj.x, obj.y)) return false;

        // まだ余裕があるなら、ここで持つ
        if ((int)objects_.size() < capacity_ && !divided_) {
            objects_.push_back(obj);
            return true;
        }

        // 容量オーバーなら4分割する
        if (!divided_) Subdivide();

        // 子のどれかに入れてもらう
        if (nw_->Insert(obj)) return true;
        if (ne_->Insert(obj)) return true;
        if (sw_->Insert(obj)) return true;
        if (se_->Insert(obj)) return true;
        return false;
    }

private:
    void Subdivide() {
        float hw = boundary_.hw / 2.0f;
        float hh = boundary_.hh / 2.0f;
        float cx = boundary_.cx;
        float cy = boundary_.cy;

        nw_ = std::make_unique<Quadtree>(Rect{cx - hw, cy - hh, hw, hh}, capacity_);
        ne_ = std::make_unique<Quadtree>(Rect{cx + hw, cy - hh, hw, hh}, capacity_);
        sw_ = std::make_unique<Quadtree>(Rect{cx - hw, cy + hh, hw, hh}, capacity_);
        se_ = std::make_unique<Quadtree>(Rect{cx + hw, cy + hh, hw, hh}, capacity_);
        divided_ = true;

        // すでに持っていた分を子に配り直す
        for (const auto& o : objects_) {
            nw_->Insert(o) || ne_->Insert(o) || sw_->Insert(o) || se_->Insert(o);
        }
        objects_.clear();
    }

    Rect boundary_;
    int  capacity_;
    bool divided_;
    std::vector<GameObject> objects_;
    std::unique_ptr<Quadtree> nw_, ne_, sw_, se_;
};

unique_ptr を使っているので、delete を書かなくても木ごと自動で片付きます(スマートポインタの記事もどうぞ)。

近くのオブジェクトだけを取り出す

四分木の本領はここです。指定した範囲と重なる区画だけを辿るので、遠くの区画は丸ごとスキップできます。次の Query は、さきほどのクラスの public: の中(Insert の下)に追加してください。

    // 範囲内にいるオブジェクトを集める
    void Query(const Rect& range, std::vector<GameObject>& out) const {
        // 重なっていない区画は、中を見る必要すらない
        if (!boundary_.Intersects(range)) return;

        for (const auto& o : objects_) {
            if (range.Contains(o.x, o.y)) out.push_back(o);
        }

        if (divided_) {
            nw_->Query(range, out);
            ne_->Query(range, out);
            sw_->Query(range, out);
            se_->Query(range, out);
        }
    }

使うときは、毎フレーム木を作り直して、自分の周辺だけ問い合わせます。

void UpdateCollision(std::vector<GameObject>& objects) {
    // 画面全体(640x480想定)で木を作り直す
    Quadtree tree(Rect{320.0f, 240.0f, 320.0f, 240.0f});
    for (const auto& o : objects) tree.Insert(o);

    std::vector<GameObject> nearby;
    for (const auto& o : objects) {
        nearby.clear();
        // 自分の周り32ピクセルだけを問い合わせる
        tree.Query(Rect{o.x, o.y, 32.0f, 32.0f}, nearby);

        for (const auto& other : nearby) {
            if (o.id == other.id) continue; // 自分自身は飛ばす
            // ここで実際の当たり判定
        }
    }
}

これで、1個あたりが調べる相手は「近所にいる数個」だけになります。1,000個でも、総当たりの約50万回が数千回まで落ちます。

【重要】私が実際に四分木で困った体験談

個人開発で弾幕シューティングに四分木を入れたとき、導入したのに逆に重くなったことがあります。

原因は容量(capacity)を1にしていたこと。1個入るたびに分割が走るので、木がひたすら深くなり、ノードを作る処理だけで時間を食っていました。4 くらいに戻したら、狙いどおり軽くなりました。

もう一つ痛かったのが、弾がすり抜けるようになったこと。これは Query の範囲を点(自分の座標だけ)で問い合わせていたせいでした。相手の大きさを考えず点で探すと、隣にいる大きな敵を見落とします。自分のサイズ+相手の最大サイズを見込んだ広さで問い合わせるようにしたら直りました。

四分木使用時のよくある失敗例と対処法

①capacityを小さくしすぎる

体験談のとおり、分割しすぎて逆に遅くなります。4〜8あたりから始めて、実測して調整するのがおすすめです。

②Queryの範囲が狭すぎてすり抜ける

点で問い合わせると、近くの大きいオブジェクトを見落とします。相手のサイズぶんの余裕を持たせた矩形で問い合わせましょう。

③同じペアを2回判定してしまう

AとB、BとAで2回当たり判定が走ると、ダメージが2倍入ります。id の大小で片方だけ処理するなどの対策が要ります。

注意点

  • オブジェクトが数十個程度なら総当たりで十分。無理に入れない
  • 四分木は絞り込み。最終的な当たり判定は別途必要
  • 毎フレーム作り直すのが単純で確実(動くオブジェクトが多いゲーム向き)

まとめ

  • 総当たりは O(n²)10倍で100倍重くなる
  • 四分木は空間を4分割して近くの相手だけ調べる仕組み
  • 実装の肝は容量オーバーで分割重なる区画だけ辿る
  • capacityの設定とQuery範囲の広さに注意する

四分木は「重くなってから入れる」で十分間に合う最適化です。まずは総当たりで作り、オブジェクトが増えて苦しくなったら、この記事のコードを試してみてください。

関連記事