Categories
unity

My Own NavMeshAgent

While working on Mazucan I recently had an experience that made me rethink a bit about how to use NavMesh’s in Unity. Lets start with a quick talk about RigidBody and NavMeshAgent in Unity.

Unity’s NavMesh breaks down to three pieces:

  • NavMesh – which is a baked “map” of where its AI agents can go or not go (including elevation)
  • NavMeshAgent – which is the component that you add to things like the player’s character to make it recognize and use the NavMesh for path-finding
  • NavMeshObstacle – which creates holes / obstacles in the NavMesh (which we’re not going to get into here)

So you have a game where the moveable areas of the map are determined by the NavMesh and you attach NavMeshAgents on the player characters and the enemies the player fights and you can use Unity’s AI engine to take care of the path-finding you inevitably need to do because there’s lots of holes in your map. Relatively easy so far…

Now lets say you the game you’re building involves a lot of things throwing rocks at each other (like Mazucan) and you want those things to “react” to getting hit – you naturally would add a collider and rigidbody to those player and enemy pieces and it, well, sort of works… Sometimes it works great – sometimes things go flying off in weird circles, spin in place, bounce up and down rapidly.

This is because the RigidBody and the NavMeshAgent are having a disagreement on what to do with that misbehaving GameObject. The NavMeshAgent is trying to keep the GameObject on its NavMesh path and the RigidBody is trying to enforce the laws of physics – the two don’t always align – in fact, they frequently disagree – it sounds like this:

RigidBody: Hey, we just got slammed on the x-axis with another object of equal mass so we need to move that way

NavMeshAgent: No freaking way – we’re going straight because I have a path on the NavMesh and I gotta get to the endpoint

RigidBody: Screw that – we’re falling over – physics rules all!

NavMeshAgent: I am leaving our feet glued to this NavMesh – you take me off this NavMesh and I’ll be completely lost, throw exceptions, and bring this game down!

Violent spinning ensues like a cat and a dog fighting

Unity’s answer to this is to click the isKinematic flag in the RigidBody (https://docs.unity3d.com/Manual/nav-MixingComponents.html) – this is basically tantamount to telling the RigidBody that we all know the NavMeshAgent gets what it wants and sometimes the laws of physics just have to wait because NavMeshAgent has a meltdown every time it falls off the NavMesh.

Physics hates being told its Kinematic

The problem with isKinematic is that basically physics looses all the time and everything becomes kind of stiff and rigid and non-reactive to environment events. You still get colliders and whatnot, but Kinematic physics is basically like non-physics and I eventually decided I wanted my physics back for Mazucan – I want things to get whacked on the side and react, I want pieces to accidentally fall off edges, I want some amount of “randomness” introduced into the game via physics (I know – sounds backwards – physics gives you the unexpected).

There is an alternative – you *can* make your own NavMeshAgent and re-use the existing NavMesh for path finding. To be fair, this wasn’t my idea – I got it after reading some Reddit posts that I frankly lost track of where someone else was suggesting to just get corners off a NavMesh path and use them like way-points. At first, I dismissed the idea – later I realized it had a lot of merit. Here’s how it winds up working – assuming you already have a valid NavMesh setup – and yes, you’re going to have to write some code:

  • Add “using UnityEngine.AI” to you code
  • Create a method for setting the destination which takes a Vector3 for a destination – this method will need to call the NavMesh calculatePath based on the destination and get back a NavMeshPath
  • Inside that NavMeshPath are its corners – this is literally just an sequenced array of Vector3’s representing each turning point on the path – save that at the class level cause you’re going to continually reference that
  • Inside your update function, you’re going to iterate over each Vector3 in that array and do something to move towards the waypoint (Vector3.moveTowards or in the below example calling addForce because it creates a nice rolling motion on the rocks I am rolling along the NavMesh)
  • Each time you reach one of the Vector3’s, move on to the next – you’re going to need to track which one you’re on (i.e. currentPathCorner in the below)
  • You might also need to turn each time you reach a corner to be pointing in the correct direction
  • Wrap it all in booleans so you can minimize the impact of having this in your update function (i.e. don’t execute the code if you’re at your final destination)

The net result is you no longer have a NavMeshAgent, but you can still leverage the NavMesh for path finding (which is a much harder thing to “roll your own”) and now you get happy little accidents when things get too close to edges:

One zinger in this is the difference in Y coordinates that the NavMesh wants versus the Y coordinates you use for your destination. All the Vector3’s from the NavMesh have a Y coordinate that’s on the NavMesh – if you use that as-is, your player pieces will try to shove themselves in the ground (assuming their pivot point is in their center which is typically is). You can recalibrate around this by taking all the corner Vector3’s and resetting their Y coordinates to the Y coordinate of the GameObject being moved. Remember, the NavMesh only knows how to path a destination that’s actually on it, with the same Y coordinate.

This is a rough version of what I wound up doing in Mazucan – there’s obviously a lot more to it, but what’s below is the core guts of the process.

// NOTE YOU CANNOT USE THIS CODE AS-IS
// IT ASSUMES YOU HAVE A WHOLE OTHER BLOCK OF CODE
// THAT TELLS IT WHERE TO GO AND THAT YOU
// WANT TO MOVE AROUND VIA APPLYFORCE AND OTHER
// STUFF - USE IT FOR REFERENCE ONLY

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

public class NavMeshAgentChaser : MonoBehaviour
{
    public float movementSpeed = 1;
    public float turningSpeed = 30;

    // internal private class vars
    private bool isMoving = false;
    private bool isTurning = false;
    private Vector3 recalibratedWayPoint;
    private NavMeshPath path;
    private int currentPathCorner = 0;
    private Quaternion currentRotateTo;
    private Vector3 currentRotateDir;
    private Vector3 groundDestination;
    private Rigidbody rb;

    void Start()
    {
        rb = transform.GetComponent<Rigidbody>();
    }

    private void Update()
    {
        if (isMoving)
        {
            // account for any turning needed
            if (isTurning)
            {
                transform.rotation = Quaternion.RotateTowards(transform.rotation, currentRotateTo, Time.deltaTime * turningSpeed);
                if (Vector3.Angle(transform.forward, currentRotateDir) < 1) isTurning = false;
            }

            // applying force gives a natural feel to the rolling movement 
            Vector3 tmpDir = (recalibratedWayPoint - transform.position).normalized;
            rb.AddForce(tmpDir * movementSpeed * Time.deltaTime);

            // check to see if you got to your latest waypoint
            if (Vector3.Distance(recalibratedWayPoint, transform.position) < 1)
            {
                currentPathCorner++;
                if (currentPathCorner >= path.corners.Length)
                {
                    // you have arrived at the destination
                    isMoving = false;
                }
                else
                {
                    // recalibrate the y coordinate to account for the difference between the piece's centerpoint
                    // and the ground's elevation
                    recalibratedWayPoint = path.corners[currentPathCorner];
                    recalibratedWayPoint.y = transform.position.y;
                    isTurning = true;
                    currentRotateDir = (recalibratedWayPoint - transform.position).normalized;
                    currentRotateTo = Quaternion.LookRotation(currentRotateDir);
                }
            }
        }
    }


    public void setMovementDestination(Vector3 tmpDest)
    {
        groundDestination = tmpDest;
        groundDestination.y = 2;
        currentPathCorner = 1;
        path = new NavMeshPath();
        NavMesh.CalculatePath(transform.position, groundDestination, NavMesh.AllAreas, path);
        // sometimes path winds up having 1 or less corners - skip this setting event if that's the case
        if (path.corners.Length > 1)
        {
            isMoving = true;
            isTurning = true;
            // recalibrate the y coordinate to account for the difference between the piece's centerpoint
            // and the ground's elevation
            recalibratedWayPoint = path.corners[currentPathCorner];
            recalibratedWayPoint.y = transform.position.y;
            currentRotateDir = (recalibratedWayPoint - transform.position).normalized;
            currentRotateTo = Quaternion.LookRotation(currentRotateDir);
        }
    }


}