🚴

[Unity][C# Script] かしこくエモい動きの敵キャラはNavMeshを使いこなせ。

2021/02/23に公開

Unityで敵キャラクタを動かすときに、最も頼りになる機能といえばNavMeshでしょう。プレイヤーキャラクタを追っかけたり、いくつかの箇所を巡回させたりすることが簡単に表現できます。これぞ、ゲームエンジンの利点といえる機能ではないでしょうか。
 この記事では、NavMeshの基本的な設定方法と、具体的なキャラクタの移動方法についてまとめてみます。
 ちなみ、NavMesh機能にて移動するキャラクタは、NavMesh平面に張り付くよう強制的に補正されるため、基本的にジャンプをするときはNavMeshAgent機能を一時的にオミットすることになります。
 なお、この記事ではオフメッシュリンクは扱いません。

1.NavMeshの設定

1) 基本的な使い方

Navigation Mesh(Navmesh)は、地形に移動可能な領域を設定する「NavMesh」と、GameObjectを移動させるための「NavMeshAgent」の2つで機能します。
 それと、ナビメッシュ障害物「NavMesh Obstacle」も4項で紹介します。

(1) NavMeshの設定
 地形を配置し、Menu: Window > AI > Navigation にて Navigationパネルを追加。
ナビゲーションに影響を与えるシーンジオメトリ (歩行可能サーフェスと障害物) を 選択 します。
 通り道にある床、壁すべてをStaticにしておいてください。そうしないと、NavMesh機能ではその壁達はないものとして扱われてしまいます。

Navigationタブ > Bake > Bake で、Navmeshが設定されます。

NavMeshが意図しないところにできた場合は、NavMesh Cleanerというアセット(無料)で消すことができます。便利。
https://assetstore.unity.com/packages/tools/ai/navmesh-cleaner-151501?aid=1011lGbg&utm_source=aff

Navigationタブの設定の詳細についてはUnityマニュアルを参照してください。
https://docs.unity3d.com/ja/2020.2/Manual/nav-BuildingNavMesh.html

(2) NavMeshAgentの設定
移動させたいキャラクタのGameObjectをシーンに配置して、Inspector: Add Component > Navigation > Nav Mesh Agent を追加してください。

ここではとりあえずパラメータをいじらずに進めますが、この NavMeshAgentは移動に関する設定ができます。調整の詳細についてはUnityマニュアルを参照してください。
https://docs.unity3d.com/ja/2020.2/Manual/class-NavMeshAgent.html

(3) ScriptからGameObjectを動かしてみる。
 まずは Inspector: Add Compornent から、New Script > NavMeshControl で新規スクリプトを追記し、以下の内容を上書きしてください。

using UnityEngine;
using UnityEngine.AI;

public class NavMeshControl : MonoBehaviour
{
    // 目的地となるGameObjectをセットします。
    public Transform target;
    private NavMeshAgent myAgent; 

    void Start()
    {
        // Nav Mesh Agent を取得します。
        myAgent = GetComponent<NavMeshAgent>();
    }

    void Update()
    {
        // targetに向かって移動します。
        myAgent.SetDestination(target.position);
    }
}

Scriptにtargetの欄が追加されるので、そこにゴールとするGameObjectを設定します。

これだけで、NavMeshは動いてしまいます。すごいですね。

2.NavMesh構築機能を拡張するコンポーネント

NavMeshは基本的にシーンにひとつです。これで困るゲームもたくさんあると思いますが、そのためにNavMesh building componentsという拡張機能があります。
今後はNavMeshはこれを使って拡張していく方針のようです。
ですので、せっかくですしこれで地形をPrefabにしてみたりしてみます。

https://docs.unity3d.com/2021.3/Documentation/Manual/NavMesh-BuildingComponents.html

1) 導入

実験的なパッケージ扱いなので、レジストリパッケージを名前で追加する手順に従い、com.unity.ai.navigationパッケージを追加します。
 Package Manager -> PackagesをUnity Registryにし、左上の「+」から Add package from git URLを選択

com.unity.ai.navigation を入力し、Addします。コピペが確実ですね。

AI Navigation がインストールされました。

2) 基本的な使い方

地形を構成するGameObjectをまとめて子にしておき、親にNavMeshSurfaceコンポーネントを追加して使います。

(1) ためしにプリミティブオブジェクトで「Stage00」GameObjectを作成してみます。

(2) Stage00のコンポーネントに NavMeshSurfaceを追加します。

(3) Collect LayersをChildrenに変更し、Bakeします。

ここでVolumeを選ぶとベイクするエリアを指定できるようになります。
動的なベイクと組み合わせることにより、NavMeshエリアを切り替えながら移動するといったことができるようになります。
オープンワールドのゲームと相性がよさそうですね。

子オブジェクトにNavMeshが作成されます。

これで、このStage00をprefabにすると、NavMeshも一緒に保存されますので、様々なステージをゲーム内で自由に入れ替えることができるようになります。

3) NavMeshの動的なベイク

NavMeshSurfaceをとりつけたGameObjectは、動的なベイクが可能になります。
ゲーム内でスイッチ橋をかけたり、移動するあるいは物理エンジンで積み重なったオブジェクトの上を渡って歩く、などに使えるかもしれません。

navMesh = GetComponent<NavMeshSurface>();
navMesh.BuildNavMesh();

4) 複数の条件の異なるNavMeshを同時に使う

GameObjectには、NavMeshSurface複数とりつけることができます。
たとえばAgentTypeにOgre(太くて大きい)を追加し、

Ogre用のNavMeshSuefaceを追加することで、HumanoidとOgreが歩ける場所を区分することができるようになります。

3.NavMeshを使いこなしてみよう

1) A地点からB地点まで通れるルートがあるか判定

現在のUnityは、NavMeshを動的に生成できます。(詳細はまた別途)
ですから、自動生成の地形を使う場合、まずNavMeshを作り、スタート地点とゴール地点まで通り抜けることができるかをチェックすることができれば、その地形がクリア可能かを判断する材料になります。
もしこのチェックが通らなかったダンジョンは破棄し再生成するなどでステージクリアできること確保できそうです。

// NavMeshのパスを取得します。
var path = new NavMeshPath();
NavMesh.CalculatePath(transform.position, target.position, NavMesh.AllAreas, path);

// パスの到達地点がゴール付近であれば、到達するルートがある
var length = path.corners[path.corners.Length - 1] - target.position;
if (length.magnitude < 1.0f)
    Debug.Log( "ルートはありまぁす!");

また、この方法で入手したパスは、スタートとゴールを結ぶ最短経路となります。
これを利用してアイテム配置位置を調整したりできそうですね。
また、このパス情報をもとにゴール方向を示す矢印を表示すれば、ナビゲーションシステムを作ることができそうです。

2) 移動する壁などの設定

NavMeshは基本的にStaticのGameObjectにベイクして使いますが、もちろん道を塞いだりするように動くGameObjectのことも考慮され、対応しています。
 やりかたは簡単で、動く障害物となるほうのGameObjectにNavMesh Obstacle(ナビメッシュ障害物)コンポーネントをアタッチするだけです。
 それだけで、NavMeshAgentは、自動的にその障害物を避けるようになります。
 例えば、物理システムによって制御された落下物や扉なども「障害物」を避けるように動いたり、扉が開いたらそこを通ろうとするように動くようにできそうです。

NavMesh Obstacleは、Inspector: Add Compornent > Navigation > Nav Mesh Obstacle にてアタッチできます。

3) よりゲーム然とした動きに調整

いまのままでは、敵キャラクタが回転しつつも後ろに歩いたり横滑りしたりと、あまりかっこいいとは言い難い動きをします。
 そこで、経路はUnityに探してもらうとして、そこまでどう動くかは自分でコントロールすることができると、自分のゲームにあった動きにできると思います。
 そのために、次に到達すべき位置をNavMesh機能の一部であるmyAgent.steeringTargetにて取得し、そこまでのGameObjectの動きを自分で設定する方法を考えてみます。

(1) NavMesh機能により、GameObjectが動いたりするのを停止させます。
・Nav Mesh Agentコンポーネントで自動的に移動を無効にするために、Steeringの以下の数値をすべて0にします。
 ・Speed (速度)
 ・Angular Speed (回転速度)
 ・Acceleration (加速度)

(2) NavMesh機能を使い、次の到達点を取得します。
 ・myAgent.steeringTargetにて、次に目指すべき位置(Vector3/position)を取得します。
 ・この情報をもとに、以下を確認できます。それによりふるまいを変えることができます。
   ①自分がその位置のほうを向いているか
   ②その位置を向いていない場合は、その方向に旋回します。
    その際、向きが大幅に違う場合は移動せず、その場で旋回するようにもできます。
   ③向いている方向に歩かせます。旋回の度合いに応じた速度を設定できます。
   ④歩いている速度に応じて、必要に応じてアニメーションのパターンや、
    アニメーションの再生速度を変更します。

(3) NavMesh機能が内部で管理する座標を、GameObjectの座標と一致させます
 NavMesh機能をつかいつつ、独自の動作をさせる場合は、以下のおまじないが必須になります。
・myAgent.nextPosition = transform.position;
 (NavMeshAgent内部の制御座標を実座標にアジャストします)

void Update()
{
   // 次に目指すべき位置を取得
   var nextPoint = myAgent.steeringTarget;
   Vector3 targetDir = nextPoint - transform.position;

   // その方向に向けて旋回する(360度/秒)
   Quaternion targetRotation = Quaternion.LookRotation(targetDir);
   transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, 360f * Time.deltaTime);

   // 自分の向きと次の位置の角度差が30度以上の場合、その場で旋回
   float angle = Vector3.Angle(targetDir, transform.forward);
   if (angle < 30f)
   {
      transform.position += transform.forward * 5.0f * Time.deltaTime;
      // もしもの場合の補正
      //if (Vector3.Distance(nextPoint, transform.position) < 0.5f) transform.position = nextPoint;
    }

   // targetに向かって移動します。
   myAgent.SetDestination(target.position);
   myAgent.nextPosition = transform.position;
}

4) NavMesh領域から外れたときの補正

ゲームルールで、ランダムな位置に敵キャラをポップさせたいということはよくあると思います。
 しかし、そのような場合、乱数で求めた座標を NavMesh 上に限定することはなかなか難しい場合があります。もしGameObjectがNavMesh外に出てしまったNavMeshAgentは動かなくなってしまいますので、それを回避するために、NavMesh.SamplePosition を使ってみます。
 この命令は、もしNavMesh外に出てしまっている場合はそこから一番近いNavMeshのPointを取得します。その情報をもとに、GameObjectの位置を補正しましょう。

NavMeshHit hit;
if (NavMesh.SamplePosition(transform.position, out hit, 1.0f,NavMesh.AllAreas))
{
   // 位置をNavMesh内に補正
   transform.position = hit.position;
}

また、ノックバックなどでGameObjectを吹き飛ばすときも、この仕組でNavMesh外には飛び出ないように抑止することができそうですね。
あるいは、落下してしまったGameObjectの復帰ポイントを見つけるときにも役立ちそうです。

5) NavMesh外にジャンプしたいときなど

いったんNavMeshAgentをオフにして、ジャンプ処理後にオンにもどすとよいようです。

// myAgent = GetComponent<NavMeshAgent>(); 別の場所で定義

myAgent.enabled = false; // 一時的にNavMeshAgentをオフにする。
// ~ジャンプ処理など
myAgent.enabled = true; // 終わったらオンにもどす

4.Navmeshの理屈

基本概念3個
・ナビメッシュ(NavMesh) (Navigation Mesh の略)
 ナビメッシュは、ゲーム世界の中で歩行可能な面を描写するデータ構造で、ひとつの歩行可能な面から別の歩行可能な面へのパスを見つけることができるようにするものです。データ構造は、ステージのジオメトリ(形状)をもとにUnityが構築またはベイクします。
ナビメッシュのデータはProjectのSceneの下のScene名のフォルダの下に作られます。(NavMesh.assetという名称です)
なお、地形の配置等を変更した際は、再度ベイクしなおす必要があります。

・ナビメッシュ エージェント(NavMesh Agent)
ナビメッシュ エージェントコンポーネントは、キャラクターがゴールに向かって進行する際に相互に回避し合うように作成する助けになります。エージェントはナビメッシュを使用してゲーム世界を認識(判断)し、互いに回避し合ったり、障害物を動かしたりする方法を理解しています。

・ナビメッシュ障害物(NavMesh Obstacle)
ナビメッシュ障害物コンポーネント。これによって、エージェントがゲーム世界をナビゲートする(動き回る)際に避けるべき障害物を、動かすことができます。例えば、物理システムによって制御された樽や、扉なども「障害物」の一例です。障害物が動いている間は、エージェントはそれを回避しようと最善を尽くします。
 障害物の動きが止まるとそれによってナビメッシュに穴が開けられるので、エージェントはそれを避けて通るために進行コースを変更することができます。あるいは、その静止した障害物が通り道を塞いでいる場合は、エージェントはよりよいルートを見つけることができます。

5.NavMesh移動スクリプトサンプル(全部)

今回のサンプルをまとめたものです。

NavMeshControl.cs
using UnityEngine;
using UnityEngine.AI;

public class NavMeshControl : MonoBehaviour
{
    public Transform target;

    private NavMeshAgent myAgent;

    void Start()
    {
        // Nav Mesh Agent を取得します。
        myAgent = GetComponent<NavMeshAgent>();

               NavMeshHit hit;
        if (NavMesh.SamplePosition(transform.position, out hit, 1.0f, NavMesh.AllAreas))
        {
            transform.position = hit.position;
        }

        //NavMeshPath path;
        var path = new NavMeshPath();
        NavMesh.CalculatePath(transform.position, target.position, NavMesh.AllAreas, path);

        var length = path.corners[path.corners.Length - 1] - target.position;
        if (length.magnitude > 1.0f)
            Debug.Log( "到達しません");
    }

    void Update()
    {

        // 次に目指すべき位置を取得
        var nextPoint = myAgent.steeringTarget;
        Vector3 targetDir = nextPoint - transform.position;

        // その方向に向けて旋回する(120度/秒)
        Quaternion targetRotation = Quaternion.LookRotation(targetDir);
        transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, 120f * Time.deltaTime);

        // 自分の向きと次の位置の角度差が30度以上の場合、その場で旋回
        float angle = Vector3.Angle(targetDir, transform.forward);
        if (angle < 30f)
        {
            transform.position += transform.forward * 5.0f * Time.deltaTime;
            // もしもの場合の補正
            //if (Vector3.Distance(nextPoint, transform.position) < 0.5f) transform.position = nextPoint;
        }

        // targetに向かって移動します。
        myAgent.SetDestination(target.position);
        myAgent.nextPosition = transform.position;

    }
}

ex.関係ありそうな記事

https://note.com/k1togami/n/n75b6c1659654
https://note.com/k1togami/n/n2a678ccf426c

Discussion