【ゲーム制作】トンネリング現象とは?|原因と解決策を初心者向けにわかりやすく解説

サムネイル画像 ゲーム制作

今回は、【ゲーム制作】トンネリング現象とは?について解説します。

ゲーム制作をしていると、「弾が壁をすり抜けた」とか、「高速で動いた時だけ当たり判定がおかしくなる」といった現象に出会うことがありますよね。

この現象が、トンネリング現象です。

特に、弾・ダッシュ・高速移動する敵などを作り始めるとかなり起きやすいです。普通のAABB判定はできているのに、速度を上げた瞬間にすり抜け始めるので、最初はかなり混乱しやすいです。

この記事では、トンネリング現象の意味・なぜ起きるのか・どう解決すればいいのかを、初心者向けにわかりやすくまとめていきます。AABBの基本から見直したい方は、先にDirectXでAABBの当たり判定をする方法も読んでおくと理解しやすいです。

トンネリング現象とは?

トンネリング現象とは、本当は途中で障害物に当たっているはずなのに、判定のタイミングの都合でそのまますり抜けてしまう現象のことです。

たとえば、1フレーム前は壁の左にいて、次のフレームでは壁の右に移動していたとします。この時、「今の位置だけ」で当たり判定していると、壁の中にいた瞬間を見逃してしまうことがあります。

つまり、移動中の経路を見ずに、フレームごとの点だけを見ていることが原因です。

なぜトンネリング現象が起きるの?

一番大きな理由は、1フレームごとに離散的に判定しているからです。

  • 前フレームでは壁の手前にいる
  • 次フレームでは壁の奥にいる
  • でも、その間の移動経路はチェックしていない

この状態だと、障害物が薄かったり、移動量が大きかったりすると、途中で通過した事実を拾えません

また、deltaTimeとは?の記事でも触れたように、1フレームの経過時間が大きいほど1回の移動量も大きくなりやすいです。つまり、処理落ちや速度の上げすぎもトンネリングを起こしやすくします。

まずは「すり抜ける例」を見てみる

まずは、あえてトンネリングが起きるシンプルなコードを見てみます。今回は横方向だけの簡単な例にしています。

#include <iostream>

struct AABB
{
    float x;
    float width;
};

bool IsHit(const AABB& a, const AABB& b)
{
    return !(a.x + a.width < b.x || b.x + b.width < a.x);
}

int main()
{
    AABB bullet = { 0.0f, 8.0f };
    AABB wall = { 20.0f, 4.0f };

    float speed = 300.0f;
    float deltaTime = 0.1f;

    bullet.x += speed * deltaTime;

    if (IsHit(bullet, wall))
    {
        std::cout << "壁に当たりました\n";
    }
    else
    {
        std::cout << "壁をすり抜けました\n";
    }

    std::cout << "弾のx座標: " << bullet.x << "\n";

    return 0;
}

実行結果

壁をすり抜けました
弾のx座標: 30

この例では、弾は0 → 30へ一気に移動しています。壁はx=20〜24にあるので、本当は途中で通っているはずです。ですが、移動後の位置だけを見ているので、すり抜けたように見えてしまいます。

解決策1:移動量を分割する

初心者の方がまず試しやすいのが、1フレームの移動を細かく分割して、そのたびに判定する方法です。

たとえば1回で30進むのではなく、3ずつ10回に分けて進めれば、壁との重なりを途中で拾いやすくなります。

#include <iostream>

struct AABB
{
    float x;
    float width;
};

bool IsHit(const AABB& a, const AABB& b)
{
    return !(a.x + a.width < b.x || b.x + b.width < a.x);
}

int main()
{
    AABB bullet = { 0.0f, 8.0f };
    AABB wall = { 20.0f, 4.0f };

    float speed = 300.0f;
    float deltaTime = 0.1f;
    int splitCount = 10;

    float totalMove = speed * deltaTime;
    float stepMove = totalMove / splitCount;

    bool isHit = false;

    for (int i = 0; i < splitCount; i++)
    {
        bullet.x += stepMove;

        if (IsHit(bullet, wall))
        {
            isHit = true;
            break;
        }
    }

    if (isHit)
    {
        std::cout << "壁に当たりました\n";
    }
    else
    {
        std::cout << "壁をすり抜けました\n";
    }

    std::cout << "弾のx座標: " << bullet.x << "\n";

    return 0;
}

実行結果

壁に当たりました
弾のx座標: 15

この方法はとても分かりやすく、実装しやすいのが強みです。まずはここから始めるのがかなりおすすめです。

ただし、速度が極端に速い時は分割数をかなり増やす必要があり、オブジェクト数が多いと重くなりやすいです。

解決策2:前フレーム位置から「通った経路」を判定する

もう1つの考え方が、前フレーム位置から今フレーム位置までの経路そのものを判定する方法です。

これはゲームエンジンなどでいうCCD(Continuous Collision Detection)に近い考え方です。一般的には、移動方向へ形を掃引してTime Of Impactを求めたり、移動量に応じてAABBを広げて候補を拾う方法が使われます [Unity Manual]

ここでは初心者向けに、横移動の簡易版として「前の位置と今の位置の間に壁区間が含まれているか」を見る例にします。

#include <iostream>
#include <algorithm>

struct Bullet
{
    float x;
    float width;
};

struct Wall
{
    float x;
    float width;
};

bool IsSweptHit(float oldX, float newX, float bulletWidth, const Wall& wall)
{
    float moveMin = std::min(oldX, newX);
    float moveMax = std::max(oldX + bulletWidth, newX + bulletWidth);

    float wallMin = wall.x;
    float wallMax = wall.x + wall.width;

    return !(moveMax < wallMin || wallMax < moveMin);
}

int main()
{
    Bullet bullet = { 0.0f, 8.0f };
    Wall wall = { 20.0f, 4.0f };

    float oldX = bullet.x;
    float speed = 300.0f;
    float deltaTime = 0.1f;

    bullet.x += speed * deltaTime;
    float newX = bullet.x;

    if (IsSweptHit(oldX, newX, bullet.width, wall))
    {
        std::cout << "通過経路上で壁に当たりました\n";
    }
    else
    {
        std::cout << "当たっていません\n";
    }

    return 0;
}

実行結果

通過経路上で壁に当たりました

本格的なゲームでは、これを2Dや3Dに広げていくイメージです。高速弾や細い壁が多いなら、分割移動よりこちらの考え方の方が本命になることが多いです。

どの解決策を選べばいい?

方法メリット注意点
移動量を分割する実装が簡単で初心者向け速度や物体数が増えると重くなりやすい
経路ごと判定する(CCD寄り)高速移動に強い実装がやや難しい
速度やdeltaTimeを見直すすぐ試せる根本解決にならないこともある

まずは分割移動で安定させて、必要になったら経路判定やCCDへ進む、という流れがかなりおすすめです。

値の確認や原因切り分けをしたい時は、C++でデバッグのやり方の記事のように、移動前座標・移動後座標・判定結果をログで出すとかなり追いやすくなります。

私が実際にトンネリング現象で困った体験談

私も以前、2Dシューティング風の処理を作った時に、弾速を上げた瞬間だけ敵をすり抜けることがありました。

最初はAABBの式が間違っているのかと思っていたのですが、原因は式ではなく、1フレームで進みすぎていたことでした。低速だと普通に当たるのに、高速化した瞬間だけ当たらなくなったので、かなりハマりました。

そこで、まずは移動を分割して確認したところ安定し、その後に前フレーム位置を保存して経路判定する形へ直したら、かなり安心して使えるようになりました。

この経験から、当たり判定そのものだけでなく、「1フレームでどれだけ動くか」までセットで考えるのが大事だと実感しました。

トンネリング現象対策でよくあるエラーと対処法

  • 1. 当たり判定の式だけ直そうとしてしまう
    実は式ではなく、移動量が大きすぎるケースがかなり多いです。まずは前フレーム位置と今フレーム位置を見比べるのがおすすめです。
  • 2. splitCountを固定で小さくしすぎる
    速度が上がると、分割数が足りずにまだすり抜けることがあります。速度に応じて分割数を増やすか、CCD寄りの方法に切り替えると安定しやすいです。
  • 3. deltaTimeが大きい時の対策をしていない
    処理落ちすると1フレーム移動量が一気に増えます。deltaTimeの上限を設ける、更新を固定時間化するといった対策も有効です。

まとめ

  • トンネリング現象は、高速移動で当たり判定をすり抜ける現象
  • 原因は、移動経路を見ずにフレームごとの位置だけを見ていること
  • まず試しやすい解決策は、移動量を分割して途中でも判定すること
  • より強い対策として、前フレーム位置から経路判定するCCD寄りの考え方がある
  • 速度・deltaTime・障害物の薄さもセットで見ると原因を見つけやすい

トンネリング現象は、ゲーム制作を始めたばかりの頃だと「なんで式は合っているのに当たらないの?」とかなり混乱しやすいポイントです。

ですが、「位置だけではなく、通った経路を見る」と考えるようになると、一気に理解しやすくなります。まずは分割移動からでも十分なので、ぜひ少しずつ試してみてください。

関連記事