テラシュールブログ

旧テラシュールウェアブログUnity記事。主にUnityのTipsやAR・VR、ニコニコ動画についてのメモを残します。

【Unity】ECSでもNavMeshを使って移動範囲を限定したい

f:id:tsubaki_t1:20191026222901g:plain

ECSでNavMeshを使いたい

 先日行われた1WeekGameJamの実況にてユニティちゃんが走り回るゲームがあり、それを見て何となくECS(SubScene)でもNavMeshによる移動制限を使用したいと思ったので、やり方を考えてみました。

 考え的には大昔に書いたマップから落ちないようにステージ上を歩かせる楽な方法 - テラシュールブログと同じ考えです。このアプローチはそうそうコリジョン抜けによる落下や貫通が無く、またPhysicsを使用しなくても良いので計算数が少ないとかなり良いです(ジャンプが無ければ)

tsubakit1.hateblo.jp

SubSceneでNavMeshをロードできるようにする

 最初にNavMeshをSubSceneでもロード出来るようにすることを考えてみます。

 NavMeshのベイクはNavMeshSurfaceで行います。これはエディターの機能でベイク(シーンに紐づく)場合、データの更新が面倒くさいという理由があります。さてNavMeshSurfaceはECSへの変換コードを持っていないので、ECSからNavMeshをロードできるようにConversionSystemを用意してやります。

f:id:tsubaki_t1:20191116105908j:plain
NavMeshのベイク

 NavMeshSurfaceを変換し、SharedComponentDataとして保存するコードです。SharedComponentDataProxyを使用しているので、ファイル名はNavMeshSurfaceComponent.csとする必要があります。内容はNavMeshDataをNavMeshSurfaceData にコピーしているだけです。

using UnityEngine.AI;
using Unity.Entities;
using System;
[assembly: RegisterGenericComponentType(typeof(NavMeshSurfaceData))]

// Proxy
[System.Serializable]
public class NavMeshSurfaceComponent : SharedComponentDataProxy<NavMeshSurfaceData> { }

// データを保持する実体
[System.Serializable]
public struct NavMeshSurfaceData : ISharedComponentData, IEquatable<NavMeshSurfaceData>
{
    public NavMeshData navmeshData;
    public bool Equals(NavMeshSurfaceData other) => navmeshData == other.navmeshData;
    public override int GetHashCode() => navmeshData.GetHashCode();
}

// NavMeshSurfaceから変換するコンバージョンシステム
public class NavMeshSurfaceConversion : GameObjectConversionSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((NavMeshSurface navmesh) =>
        {
            var entity = GetPrimaryEntity(navmesh.gameObject);
            DstEntityManager.AddSharedComponentData(entity, new NavMeshSurfaceData {
                navmeshData = navmesh.navMeshData
            });
        });
    }
}

 データをロードします。このロード処理で少し気をつけなければいけないのが「誰かが意図せずNavMeshInstance*1を持つEntityを破棄する可能性がある」という点です。例えばSubScene内をロード・アンロードだけ考えているとSubScene内に含まれているNavMeshInstanceが破棄されてリークすることが考えられます。

 そのため、ロード処理に付随して破棄の処理も組み込みます。この「削除されたら動作する」仕組みはISystemStateComponentData を使用します。

using Unity.Entities;
using UnityEngine.AI;

// NavMeshDataをロードしたりアンロードしたりする
public class LoadNavmeshSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.WithNone<LoadedNavMesh>().ForEach((Entity entity, NavMeshSurfaceData data) => 
        {
            var instance = NavMesh.AddNavMeshData(data.navmeshData); // NavMeshをゲームに登録
            PostUpdateCommands.AddComponent(entity, new LoadedNavMesh { Value = instance});
        });

        Entities.WithNone<NavMeshSurfaceData>().ForEach((Entity entity, ref LoadedNavMesh data) => {
            data.Value.Remove(); // ロードしたNavMeshInstanceを破棄
            PostUpdateCommands.RemoveComponent<LoadedNavMesh>(entity);
        });
    }
}

// インスタンスを保持する。ISystemStateComponentData なのでEntityが破棄されても破棄されない
public struct LoadedNavMesh : ISystemStateComponentData {
    public NavMeshDataInstance Value;
}

f:id:tsubaki_t1:20191116113344g:plain
SubSceneに配置したステージでNavMeshを作る

EntityがNavMeshの外に移動したら補正する

 NavMeshの範囲外に移動するのを防ぐコードを追加します。プレイヤーやAI、その他何らかのシステムにより移動するEntityが存在している状況で、Entityがステージ外に出てしまうのを防ぎます。これをJobSystem上で行う事が目標です。これは下のコードで実現しています。

using static Unity.Entities.ComponentType;

public class AgentLimitSystem : JobComponentSystem
{
    NavMeshQuery query;

    protected override void OnCreate()
    {
        RequireForUpdate(GetEntityQuery(ReadOnly<LoadedNavMesh>(), ReadOnly<NavMeshSurfaceData>()));
    }

    protected override void OnStartRunning()=> query = new NavMeshQuery(NavMeshWorld.GetDefaultWorld(), Allocator.Persistent);

    protected override void OnStopRunning() => query.Dispose();

    protected override JobHandle OnUpdate(JobHandle inputDeps) => new LimitJob { query = query }.Schedule(this, inputDeps);

    [BurstCompile]
    [RequireComponentTag(typeof(LimitByNavmeshTag))]
    struct LimitJob : IJobForEach<Translation>
    {
        [ReadOnly] public NavMeshQuery query;

        public void Execute(ref Translation position)
        {
            var location = query.MapLocation(position.Value, math.float3(1, 1, 1), -1);
            position.Value = location.position;
        }
    }
}

public struct LimitByNavmeshTag : IComponentData { }

 少し解説すると、NavMeshを誰もロードしていない状態でこの機能を動かす訳にはいかないので、RequireForUpdate(GetEntityQuery(ReadOnly<LoadedNavMesh>(), ReadOnly<NavMeshSurfaceData>())); で誰かがロード完了するまで待ちます。ロードが完了したら RequireForUpdate の条件が揃うので NavMeshQuery を生成、LimitJob を実行します。

 LimitJobの中身は、NavMeshの情報を元に「最も近いNavMeshの範囲内に収まるように補正」しています。本当はパス計算を使いたかったのですが、保持するパス情報やハンドルが面倒くさいので今回は範囲内に収める処理だけにしました。

 あとはオーサリングコードを用意してGameObjectに追加するなり、Entityにコードで追加するなりすれば、該当のEntityはNavMeshに沿ってしか移動できなくなります。

// オーサリングコード
[DisallowMultipleComponent]
[RequiresEntityConversion]
public class LimitByNavmeshComponent : MonoBehaviour, IConvertGameObjectToEntity
{
    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        dstManager.AddComponentData(entity, new LimitByNavmeshTag());
    }
}

f:id:tsubaki_t1:20191116112033j:plain
コンポーネントに追加すれば動くのはGameObject的で楽でいい

関連

NavMeshData(アセット)を保存するために使用

tsubakit1.hateblo.jp

破棄を検出して云々する為に使用

tsubakit1.hateblo.jp

*1:NavMeshをゲーム内でハンドリングする情報