Categories
godot performance

Godot Performant Nav Agent

This is part 2 of 4 in a series about some recent performance tuning I completed on Elevation TD.

Background

Godot’s build in navigation and agent system works reasonably well. Before proceeding with the rest of this article, please be sure you’ve read and implemented everything in Godot’s Nav Agent tutorial – we are assuming you have a working nav mesh with working nav agents and want to optimize agent performance:

https://docs.godotengine.org/en/stable/tutorials/navigation/navigation_using_navigationagents.html

Problem Description

Let’s say you have hundreds of Nav Agents running concurrently in your game and you’re starting to notice that performance is lagging. Try this: take that entire block of code Godot’s tutorial tells you to put in _physics_process and comment it out – hit play again and see if you just got a whole bunch of FPS back.

The FPS you got back represents an estimate of how much you are loosing on frame-by-frame Nav Agent controls with the default implementation – of course, the problem is now nothing is moving and you still need your Agents to move around and follow their paths to their targets.

Leave that code commented out and add the following – first, add two class-level vars called currentNavArray and nextWayPoint – then modify the set_movement_target function:

...in header...
var currentNavArray
var nextWayPoint

func set_movement_target(movement_target: Vector3):
  navigation_agent.set_target_position(movement_target)
  # give the agent a moment to repath itself
  await get_tree().create_timer(1.0).timeout
  # tell it to grab its next position
  navigation_agent.get_next_path_position()
  # grab the entire navigation path and store it
  # the path is simply an array of Vector3's
  currentNavArray = navigation_agent.get_current_navigation_path()
  # make sure it has at least one entry and grab the first
  if currentNavArray.size() > 0:
    nextWaypoint = currentNavArray[0]
  else:
    # otherwise leave at current location
    nextWaypoint = global_position

At this point, you have an array of the entire navigation path to the target position for this agent. You simply need to implement a very plain vanilla “go to next waypoint” system in your _process() that is much lighter-weight than what Godot’s tutorial had put in. Remove or comment out everything Godot’s tutorial put in _physics_process() and replace it with the below in either _physics_process() or _process() (this does not need to be in a _physics_process()):

if currentNavArray.size() < 2:
  # if you are almost at the end of your waypoints, 
  # don't get closer than 7 to keep a bit of distance
  if global_position.distance_to(nextWaypoint) > 7:
    global_position = global_position.move_toward(nextWaypoint,movement_speed*delta)
else:
  global_position = global_position.move_toward(nextWaypoint,movement_speed*delta)
  # if you are close to your current waypoint, get a next one
  if global_position.distance_to(nextWaypoint) < .05:
    nextWayPoint()

The above code eliminates multiple calls to the nav server to establish what the next Agent position should be in favor of a couple move_toward()’s which have much less overhead. Its important to note that one of the things the Nav Agent takes care of is not actually moving the Nav Agent right on top of the target – in the above example, if you are on the last waypoint, it will keep the agent a distance of 7 from the destination to stop it from moving right on top of the goal. If you are not on the last waypoint and you are less than .05 distance to the waypoint, then you call nextWayPoint() to get the next waypoint.

The nextWayPoint function is actually very simple:

func nextWayPoint():
  if currentNavArray.size() > 1:
    currentNavArray.remove_at(0)
    nextWaypoint = currentNavArray[0]
    nextWaypoint.x += rng.randf_range(-1,1)
    nextWaypoint.z += rng.randf_range(-1,1)
  else:
    nextWaypoint = global_position

If you have more than one more waypoint in the currentNavArray, remove position 0, pulling all the rest in the array forward, and then grab the new one at position 0. This is also an opportunity to modify what that waypoint to add some randomness to movements and avoid a bunch of enemies forming a “conga line”. If you are already at the last position, just return global_position and the agent will go no where.

Presumably, you’ll have some events in your game that reset the target of the agent – when you call set_movement_target() with that new Vector3 target position, the process will simply repeat itself: you’ll navigation_agent.set_target_position, rebuild the currentNavArray, and move waypoint-waypoint-waypoint.

Conclusion

In my case, working on Elevation TD, each enemy is a Nav Agent so I had scenarios where there might be hundreds of Nav Agents at one time. For me, the above was a significant performance boost. I can easily see that the above agent code might:

  1. Not show significant performance gains in situations where there are very few agents
  2. Would create very “coarse grained” pathing movement – its probably not “reactive” enough if you were working on an FPS-style game – but for something like an overhead strategy or tower defense game, that coarseness might not matter (may even be desirable)
  3. Forces you to implement your own Agent behavior (like not getting closer than 7 to the destination) – some of that “overhead” we’re getting rid of handles things like agent avoidance and various other nuances of agent behavior. In my case, it didn’t matter – in other cases, it might, especially if you want very fine-grained movements.

See the other parts of this series: