Unityの経路探索機能である NavMesh は、敵キャラクターやNPCのAI移動を手軽に実装するための強力な標準機能です。しかし、複数の NavMeshAgent が狭い通路やドアの前などで密集した際、「キャラクター同士が激しくガタガタと震えだす(ジッター現象)」「お互いに押し合っているうちに、壁のコライダーを突き抜けてマップ外に脱出してしまう」という深刻な挙動バグに直面することがよくあります。本記事では、この挙動不整合が発生する物理エンジンとRVOの競合原因と、現場で使われている実践的な解決アプローチを解説します。
1. 根本原因①:NavMeshAgentの座標更新と物理演算(Rigidbody)の競合
最大の発振・ジッター原因は、NavMeshAgentコンポーネントとRigidbody/Colliderによる物理演算が、同一オブジェクト上で衝突座標の書き換えを競合させていることにあります。
- NavMeshAgentの挙動: NavMeshAgentは、経路計算に基づいて毎フレームTransformの
positionを直接書き換えて移動します。これは物理演算をバイパスする強制的な位置書き換えです。 - Rigidbody/Colliderの挙動: キャラクター同士が接触すると、物理エンジン(PhysX)はコライダーの重なり(ペネトレーション)を解消するために、衝突解決プロセスでキャラクター同士を「押し戻す」力を計算し、物理サイクル(FixedUpdate)の最後でTransform座標を書き戻します。
この2つが同時に動くと、「Agentが経路に沿って相手の体に重なるように前進させる」➔「物理演算が重なりを検知して後ろへ押し戻す」➔「次のフレームでAgentが再び前進させる」 という無限ループが発生します。この座標の綱引きが、目に見える激しい振動(ジッター)の正体です。
2. 根本原因②:RVO(相互速度障害物)の密集時における回避計算の破綻
NavMeshAgentは、エージェント同士の衝突を避けるために RVO (Reciprocal Velocity Obstacles) というアルゴリズムを内蔵しています。これは「衝突する可能性のある他のエージェントの速度」を検知し、お互いに少しずつ進路を避ける速度ベクトルを計算する仕組みです。
しかし、狭い通路などの「逃げ場がない状況」で多数のエージェントが密集すると、どの方向へ進んでも「誰かと衝突する」状態になります。このとき、RVOの回避ベクトル計算が毎フレーム左右に激しくブレ、エージェントの進行方向が超高速で反転し、ジッターがさらに増幅されます。
また、この回避計算による急激な速度変化と、物理の押し戻し力が一瞬で蓄積された結果、1フレームの移動量が「壁コライダーの厚み」を超えてしまい、静的なコライダーを突き抜ける「トンネリング現象」を誘発します。
図:NavMeshAgentの移動更新とPhysX(物理エンジン)の衝突解決が競合するメカニズム
3. 解決アプローチ①:エージェントから非Kinematicな物理を完全に排除する
衝突時のガタガタと壁抜けを防ぐための大原則は、「NavMeshAgentが動作している間は、物理エンジンに位置決定権を与えない」ことです。
- 移動キャラクターに
Rigidbodyがアタッチされている場合は、Is Kinematic をtrueに設定します。 - キャラクターの
Colliderは、トリガー(Is Trigger = true)にするか、あるいは物理のレイヤー設定で「エージェント同士は衝突しないが、攻撃判定や壁コライダーには当たる」ように制限します。
エージェント同士の重なりを物理的(PhysX)に押し戻すのを諦め、衝突回避はすべてNavMeshAgent(RVO)か、スクリプトによる位置補正に一本化します。これだけで、押し合いによる激しいジッターとコライダーの壁突き抜けバグはほぼ100%解消されます。
4. 解決アプローチ②:NavMeshAgentとNavMeshObstacleの動的切り替え(最も効果的)
「複数のエージェントが移動を終えて待機状態(Idle)になった際、お互いをすり抜けず、綺麗に整列・遮蔽させたい」という場合は、停止中のキャラクターを「動的障害物(NavMeshObstacle)」に切り替えるハイブリッド設計が極めて効果的です。
キャラクターが移動中は NavMeshAgent を有効にし、目的地に到達して停止したら NavMeshAgent を無効化して NavMeshObstacle (Carve = true) を有効にします。これにより、他の移動中のエージェントは停止しているキャラクターを「歩行不可能な壁」として認識するため、押しのけようとすることなく回り込んで避けるようになります。
以下は、この動的切り替えを自動で制御するC#スクリプトです。
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(NavMeshObstacle))]
public class AgentObstacleSwitcher : MonoBehaviour
{
private NavMeshAgent agent;
private NavMeshObstacle obstacle;
[SerializeField] float stopThresholdVelocity = 0.1f;
[SerializeField] float idleSwitchDelay = 0.5f;
private float lastMoveTime;
void Awake()
{
agent = GetComponent<NavMeshAgent>();
obstacle = GetComponent<NavMeshObstacle>();
// 初期状態ではエージェントを有効、障害物を無効にする
obstacle.enabled = false;
obstacle.carving = true;
agent.enabled = true;
}
void Update()
{
// エージェントが有効で移動中の場合
if (agent.enabled)
{
if (agent.velocity.sqrMagnitude > stopThresholdVelocity * stopThresholdVelocity)
{
lastMoveTime = Time.time;
}
else if (Time.time - lastMoveTime > idleSwitchDelay)
{
// 速度が一定時間低下したら、障害物に切り替え
SwitchToObstacle();
}
}
// 障害物が有効で、外部(C#スクリプト等)から新しい目的地が設定された場合
else if (obstacle.enabled)
{
// 新しい移動指示やターゲットの移動を検知したらエージェントに戻す
// 例:外部から目的地が更新されたフラグや、自前で追跡対象が離れたのを検知した場合など
}
}
public void SetDestination(Vector3 targetPos)
{
// 目的地設定時は確実にエージェントに戻す
if (obstacle.enabled)
{
SwitchToAgent();
}
agent.SetDestination(targetPos);
}
private void SwitchToObstacle()
{
agent.enabled = false;
obstacle.enabled = true;
// Carvingが有効になることで、NavMeshに穴が開き、他のエージェントが避けるようになる
}
private void SwitchToAgent()
{
obstacle.enabled = false;
// 障害物を無効化してからNavMeshの再ベイク(Carvingの更新)が終わるのを1フレーム待つ必要がある場合があるため注意
agent.enabled = true;
}
}
5. 解決アプローチ③:RVO障害物回避クオリティの個別調整
密集時のRVO破綻を抑えるための、インスペクターから設定できるパラメータ調整です。
- Avoidance Quality(回避品質): デフォルトは `High Quality` ですが、狭い通路で多数のキャラクターが密集する場合、品質が高いほど回避ベクトルの再計算が細かくなりジッターが起きやすくなります。品質を `Low Quality` や `None` に下げることで、無駄な首振りやジッターが減少し、キャラクターが滑らかに進むようになります。
- Priority(優先度): ボスや大型キャラクターなど、押し退けられたくないエージェントの `Priority` 値を小さく(優先度を高く、0が最大優先)設定します。これにより、雑魚エージェント側が一方的に進路を譲るようになり、対等な押し合いによる衝突発振を回避できます。
まとめ
複数のNavMeshAgentが衝突した際のジッターと壁抜けは、「物理エンジン(PhysX)の無効化(Is Kinematic化)」と「停止時のNavMeshObstacleへの動的切り替え」で完全に制御可能です。ゲームのジャンルに合わせてエージェント同士の物理レイヤー設定を分離し、AI制御の座標更新を物理と競合させないクリーンな設計を徹底しましょう。