Runtime Generation of Unity NavMesh on the XY plane with 2D Physics

Introduction

When working with 2D physics in Unity, it often feels like you’re a second-class citizen. The 2D physics API and featureset has been lagging behind 3D for a while, and support for NavMesh pathfinding on the XY is… quite poorly documented. To the point I couldn’t find anyone explaining how to use it, even though the API has supported it since version 5.6!

This tutorial for Unity 2018.1 will be brief in written content, but contains a full educational demo scene that shows how to do the following:

  1. How to generate (and regenerate!) NavMeshes at runtime, on the XY plane
  2. How to handle multiple unit sizes
  3. How to use use your existing 2D Physics colliders as static barriers that block pathfinding (including Polygon2D!)
  4. How to perform pathfinding for multiple unit sizes without having to use a NavMeshAgent (so you can write your own AI code)

The tutorial assumes you’ve downloaded and looked over the High Level API Components for Runtime NavMesh Building sourcecode, provided separately by Unity.

How to generate & regenerate NavMeshes at runtime, on the XY plane

This topic is covered mostly by Unity’s own tutorials here which shows how to use NavMeshSurfaces to bake NavMeshes at any orientation at all, and the most important component – the NavMeshSurface – is further explained on the manual here. However, there are several points I’d still like to make.

  • The NavMeshSurface can be rotated to any orietentation that you like, and combined together in wacky ways – as shown in the Tutorial video. This means that having it on the XY plane is as simple as creating a NavMeshSurface and rotating it until it lays on the XY plane.
  • The NavMeshSurface supports the baking of NavMeshes that pathfind around either Meshes or 3d Physics components. There is no built-in support for 2D Physics components.
  • You’ll need one NavMeshSurface for every unit size that you want to support.

Ultimately, once you’ve wrapped your head around the concepts and catches, the baking and re-baking of a NavMesh is as simple as iterating over all of your NavMesh surfaces and call the BuildNavMesh() method. Easy!

foreach(var surface in surfaces)
    surface.BuildNavMesh();

Asynchronous updating of the NavMesh is also achievable using the NavMeshBuilder.UpdateNavMeshDataAsync method, but is not covered in this tutorial.

How to handle multiple unit sizes

The ability to add and remove different unit sizes can be done at design-time, and can be found in the Navigation window (visible by clicking on the toolbar -> Windows -> Navigation), under the “Agents” tab.

Each entry in the list allows us to specify a different unit size. While there is no ability to remove entries at runtime, we can add new entries (via the NavMesh.CreateSettings() method) and change any of the values for existing entries (the radius, height, step height, etc) at runtime, and also selectively generate NavMeshes for only specific agent types, which allows us to be as dynamic as we want!

From code, these can be accessed like so:

int navMeshBuildSettingCount = NavMesh.GetSettingsCount();
for (int i = 0; i < navMeshBuildSettingCount; i++)
{
    NavMeshBuildSettings buildSetting = NavMesh.GetSettingsByIndex(i);
    string settingName = NavMesh.GetSettingsNameFromID(buildSetting.agentTypeID);
    Debug.Log(string.Format("Agent Type Name: {0}, Radius: {1}, Height: {2}", settingName, buildSetting.agentRadius, buildSetting.agentHeight));
}

The NavMeshSurface is associated with a particular NavMeshAgentTypeID, and internally fetches the stored NavMeshBuildSetting for that AgentTypeID every time you rebake its NavMesh. Unfortunately, there doesn’t appear to be a way of persisting your desired NavMeshBuildSettings back into Unity, so for the purposes of the tutorial example scene, I updated the NavMeshSurface.BuildNavMesh() method to take a NavMeshBuildSettings struct which it will use to rebake the NavMesh.

How to use 2D Physics colliders as static barriers that block pathfinding

While NavMeshSurfaces will happily generate pathfinding ‘walls’ automatically from meshes and 3D Physics colliders that it finds, it lacks automatic support for 2D Physics colliders. If you take a look in the NavMeshSurface code, when you call the BuildNavMesh method it goes off to identify the desired meshes or 3D Physics objects and creates a list of NavMeshBuildSource structs which represent them. For 2d Physics we can take control of the situation by generating our own NavMeshBuildSource structs which we’ll manually feed into the process.

The following code is for a component that you can attach to any GameObject that has a PolygonCollider2D, BoxCollider2D, or CircleCollider2D and it will allow you to generate a NavMeshBuildSource to represent the object.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.AI;

/// <summary>
/// Custom-made class, so we can easily identify objects that we intend to incorporate into
/// the NavMesh as walls or boundaries. Also allows us to encapsulate all the code required to
/// turn the Collider2D into a NavMeshBuildSource, which is fed into the NavMesh build operation.
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class NavMeshStaticFeature : MonoBehaviour {

    const int AreaNotWalkable = 1;
    private new Collider2D collider2D;
    private PolygonCollider2D polygonCollider2D;
    private CircleCollider2D circleCollider2D;
    private BoxCollider2D boxCollider2D;
    private Mesh generatedStaticFeatureMesh;

    private void Awake()
    {
        collider2D = GetComponent<Collider2D>();
        if (null == collider2D)
            throw new Exception("Must have a collider2D attached.");

        if (collider2D.isTrigger)
            throw new Exception("Trigger colliders are not valid for being static features.");

        polygonCollider2D = collider2D as PolygonCollider2D;
        //if we've got a PolygonCollider2D, generate a mesh to represent the collider.
        if (null != polygonCollider2D)
            GenerateAndCacheMesh();

        circleCollider2D = collider2D as CircleCollider2D;
        boxCollider2D = collider2D as BoxCollider2D;

        if (null == boxCollider2D && null == circleCollider2D && null == polygonCollider2D)
            throw new Exception("NavMeshStaticFeature has only been coded to handle PolygonCollider2D, BoxCollider2D, and CircleCollider2D.");
    }

    /// <summary>
    /// Takes the series of points specified in the PolygonCollider2D, and creates a mesh.
    /// </summary>
    private void GenerateAndCacheMesh()
    {
        int[] indices = Triangulator.Triangulate(polygonCollider2D.points);
        var vertices = polygonCollider2D.points.Select(p => (Vector3)p).ToArray();

        // create mesh
        generatedStaticFeatureMesh = new Mesh
        {
            vertices = vertices,
            triangles = indices
        };
        generatedStaticFeatureMesh.RecalculateBounds();
    }

    /// <summary>
    /// Don't cache this because any movement of the gameobject will cause the buildsource to be invalidated.
    /// </summary>
    /// <param name="collider"></param>
    /// <returns></returns>
    public NavMeshBuildSource GenerateBuildSource()
    {
        var localPosition = collider2D.offset;
        var worldPositionIncludingOffset = collider2D.transform.TransformPoint(localPosition);

        NavMeshBuildSource source = default(NavMeshBuildSource);

        if (null != polygonCollider2D)
        {
            source = new NavMeshBuildSource
            {
                shape = NavMeshBuildSourceShape.Mesh,
                sourceObject = generatedStaticFeatureMesh,
                area = AreaNotWalkable,
                transform = Matrix4x4.TRS(worldPositionIncludingOffset, collider2D.transform.rotation, collider2D.transform.lossyScale)
            };
        }

        if(null != boxCollider2D)
        {
            source = new NavMeshBuildSource
            {
                shape = NavMeshBuildSourceShape.Box,
                size = boxCollider2D.size,
                area = AreaNotWalkable,
                transform = Matrix4x4.TRS(worldPositionIncludingOffset, collider2D.transform.rotation, collider2D.transform.lossyScale)
            };
        }

        if (null != circleCollider2D)
        {
            float diameter = circleCollider2D.radius * 2f;
            source = new NavMeshBuildSource
            {
                shape = NavMeshBuildSourceShape.Sphere,
                size = new Vector3(diameter, diameter, diameter),
                area = AreaNotWalkable,
                transform = Matrix4x4.TRS(worldPositionIncludingOffset, collider2D.transform.rotation, collider2D.transform.lossyScale)
            };
        }

        return source;
    }
}

Internally, NavMeshBuildSources support Box, Sphere, and Capsule shapes, which are direct fits for BoxCollider2D, CircleCollider2D, and CapsuleCollider2D, so creating NavMeshBuildSources for those is an easy task. PolygonCollider2Ds are trickier though – we need to generate a Mesh that represents the points of the PolygonCollider2D. Thankfully the dude runevision over at Unify Community has shared a script that splits a 2D polygon into triangles that we can use to build the Mesh, which makes the process very straight forward – as you can see in the code above.

How to perform pathfinding for multiple unit sizes without having to use a NavMeshAgent

After all that, this part is very straight forward. All you need to know is the AgentTypeID whose radius is the size you want. With that, you can create a NavMeshQueryFilter that specifies this is the AgentTypeID you’re interested in, and pass it along with the other parameters to NavMesh.CalculatePath method.

private void CalculatePath(Vector3 source, Vector3 destination)
{
    NavMeshQueryFilter filter = new NavMeshQueryFilter
    {
        agentTypeID = AgentTypeID,
        areaMask = NavMesh.AllAreas
    };
    NavMeshPath path3D = new NavMeshPath();
    bool result = NavMesh.CalculatePath(this.transform.position, destination, filter, path3D);
}

The result of the calculation will be stored in the final NavMeshPath parameter, which you can find out more about in the manual.

Download the Tutorial demo

The tutorial demo code is available as a unitypackage for Unity 2018.1, under the Attribution-ShareAlike license (since that’s what the Triangulator script is distributed under!)

NavMeshes on XY Plane with 2D Physics v1.1

Links

High Level API Components for Runtime NavMesh Building sourcecode: https://github.com/Unity-Technologies/NavMeshComponents

Triangulator script, used to split a 2D polygon into triangles – which we use to create Meshes for PolygonCollider2Ds: http://wiki.unity3d.com/index.php/Triangulator

Low Poly Forest Pack, used in the tutorial package: https://jaks.itch.io/lowpolyforestpack