Chapter 6: Path Following and Steering Behaviors
In this short chapter, we will implement two Unity3D demos to explore steering behaviors. In the first demo, we will implement a script to make an entity follow a simple path. In the second demo, we will set up a scene with a couple of obstacles and program an entity to reach a target while avoiding the obstacles.
Obstacle avoidance is a fundamental behavior for game characters when moving around and interacting with the game world. However, obstacle avoidance is generally used with other navigation systems (such as pathfinding or crowd simulations). In this chapter, we will use the systems to make sure that we avoid the other agents and reach the target. We will not talk about how fast the character will reach a destination, and we will not calculate the shortest path to the target, as we'll talk about these in the next chapter.
In this chapter, we'll look at the following two fundamental aspects of movement:
- Following a path
- Avoiding obstacles
Technical requirements
For this chapter, you just need Unity3D 2022. You can find the example project described in this chapter in the Chapter 6 folder in the book repository: https://github.com/PacktPublishing/Unity-Artificial-Intelligence-Programming-Fifth-Edition/tree/main/Chapter06.
Following a path
A path is a sequence of points in the game, connecting a point A to a point B. There are many ways to build a path. Usually, a path is generated by other game systems such as pathfinding (see Chapter 7, A* Pathfinding); however, in our demo, we construct the path by hand using waypoints. So first, we write a Path.cs script that takes a list of game objects as waypoints and create a path out of them.
Path script
Let's look at the path script responsible for managing the path for our objects. Consider the following code in the Path.cs file:
using UnityEngine;
public class Path : MonoBehaviour {
public bool isDebug = true;
public Transform[] waypoints;
public float Length {
get {
return waypoints.Length;
}
}
public Vector3 GetPoint(int index) {
return waypoints[index].position;
}
void OnDrawGizmos() {
if (!isDebug)
return;
for (int i = 1; i < waypoints.Length; i++) {
Debug.DrawLine(waypoints[i-1].position,
waypoints[i].position, Color.red);
}
}
}
As you can see, that is a straightforward script. It has a Length property that returns the number of waypoints. The GetPoint method returns the position of a particular waypoint at a specified index in the array. Then, we have the OnDrawGizmos method called by the Unity3D frame to draw components in the editor environment. The drawing here won't be rendered in the game view unless the gizmos flag, located in the top right corner, is turned on.
Figure 6.1 – The gizmos visibility option in the Editor view
Now let's create the scene. Create an empty Path game object and attach to it the Path script. Then, let's add to it some empty game objects as children. They will be the waypoints markers.
Figure 6.2 – Here is how we organize the Hierarchy
Select the Path object. We now have to fill the Waypoints array in the Inspector with the actual waypoint markers. As usual, we can do this by dragging and dropping the game objects from the Hierarchy to the Inspector.
Figure 6.3 – The Path script configuration in the Inspector
The preceding list shows the Waypoints in the example project. However, you can move the waypoints around in the editor, use the same waypoint multiple times, or whatever else you like.
The other property is a checkbox to enable the debug mode and the waypoint radius. If we enable the debug mode property, Unity draws the path formed by connecting the waypoints as a gizmo in the editor view as shown in Figure 6.4.
Figure 6.4 – The path's gizmo is drawn in the editor view
Now that we have a path, we need to design a character that can follow it. We do that in the following section.
Path-following agents
For this demo, the main character is represented by a brave and valiant cube. But, of course, the same script applies to whatever 3D models you want.
Let's start by creating a VehicleFollowing script. The script takes a couple of parameters: the first is the reference to the path object it needs to follow (the Path variable); then, we have the Speed and Mass properties, which we need to calculate the character's velocity over time. Finally, if checked, the Is Looping flag instructs the entity to follow the path continuously in a closed loop.
Let's take a look at the following code in the VehicleFollowing.cs file:
using UnityEngine;
public class VehicleFollowing : MonoBehaviour {
public Path path;
public float speed = 10.0f;
[Range(1.0f, 1000.0f)]
public float steeringInertia = 100.0f;
public bool isLooping = true;
public float waypointRadius = 1.0f;
//Actual speed of the vehicle
private float curSpeed;
private int curPathIndex = 0;
private float pathLength;
private Vector3 targetPoint;
Vector3 velocity;
First, we specify all the script properties. Then, we initialize the properties and set up the starting direction of our velocity vector using the entity's forward vector. We do this in the Start method, as shown in the following code:
void Start () {
pathLength = path.Length;
velocity = transform.forward;
}
In this script, there are only two methods that are really important: the Update and Steer methods. Let's take a look at the first one:
void Update() {
//Unify the speed
curSpeed = speed * Time.deltaTime;
targetPoint = path.GetPoint(curPathIndex);
//If reach the radius of the waypoint then move to
//next point in the path
if (Vector3.Distance(transform.position,
targetPoint) < waypointRadius) {
//Don't move the vehicle if path is finished
if (curPathIndex < pathLength - 1)
curPathIndex++;
else if (isLooping)
curPathIndex = 0;
else
return;
}
//Move the vehicle until the end point is reached
//in the path
if (curPathIndex >= pathLength)
return;
//Calculate the next Velocity towards the path
if (curPathIndex >= pathLength - 1 && !isLooping)
velocity += Steer(targetPoint, true);
else
velocity += Steer(targetPoint);
//Move the vehicle according to the velocity
transform.position += velocity;
//Rotate the vehicle towards the desired Velocity
transform.rotation =
Quaternion.LookRotation(velocity);
}
In the Update method, we check whether the entity has reached a particular waypoint by calculating if the distance between its current position and the target waypoint is smaller than the waypoint's radius. If it is, we increase the index, setting in this way the target position to the next waypoint in the waypoints array. If it was the last waypoint, we check the isLooping flag.
If it is active, we set the destination to the starting waypoint; otherwise, we stop. An alternative solution is to program it so that our object turns around and goes back the way it came. Implementing this behavior is not a difficult task, so we leave this to the reader as a helpful practice exercise.
Now, we calculate the acceleration and rotation of the entity using the Steer method. In this method, we rotate and update the entity's position according to the speed and direction of the velocity vector:
public Vector3 Steer(Vector3 target, bool bFinalPoint =
false) {
//Calculate the directional vector from the current
//position towards the target point
Vector3 desiredVelocity =
(target - transform.position);
float dist = desiredVelocity.magnitude;
//Normalize the desired Velocity
desiredVelocity.Normalize();
//
if (bFinalPoint && dist < waypointRadius)
desiredVelocity *=
curSpeed * (dist / waypointRadius);
else
desiredVelocity *= curSpeed;
//Calculate the force Vector
Vector3 steeringForce = desiredVelocity - velocity;
return steeringForce / steeringInertia;
}
}
The Steer method takes two parameters: the target position and a boolean, which tells us whether this is the final waypoint in the path. As first, we calculate the remaining distance from the current position to the target position. Then we subtract the current position vector from the target position vector to get a vector pointing toward the target position. We are not interested in the vector's size, just in its direction, so we normalize it.
Now, suppose we are moving to the final waypoint, and its distance from us is less than the waypoint radius. In that case, we want to slow down gradually until the velocity becomes zero precisely at the waypoint position so that the character correctly stops in place. Otherwise, we update the target velocity with the desired maximum speed value. Then, in the same way as before, we can calculate the new steering vector by subtracting the current velocity vector from this target velocity vector. Finally, by dividing this vector by the steering inertia value of our entity, we get a smooth steering (note that the minimal value for the steering inertia is 1, corresponding to instantaneous steering).
Now that we have a script, we can create an empty Cube object and put it at the beginning of the path. Then, we add the VehicleFollowing script component to it, as shown in the following screenshot:
Figure 6.5 – The properties of the VehicleFollowing script
You should see our cubic character follow the path if you run the scene. You can also see the path in the editor view. Play around with the speed and steering inertia values of the cube and radius values of the path, and see how they affect the system's overall behavior.
Avoiding obstacles
In this section, we explore obstacle avoidance. As a first step, we need, of course, obstacles. So, we set up a scene similar to the one shown in Figure 6.6. Then, we create a script for the main character to avoid obstacles while trying to reach the target point. The algorithm presented here uses the raycasting method, which is very straightforward. However, this means it can only avoid obstacles that are blocking its path directly in front of it:
Figure 6.6 – A sample scene setup
We make a few cube entities and group them under an empty game object called Obstacles to create the environment. We also create another cube object called Vehicle and give it the obstacle avoidance script. Finally, we create a plane object representing the ground.
Figure 6.7 – Here is the structure of the scene's Hierarchy
It is worth noting that the Vehicle object does not perform pathfinding, that is, the active search for a path to the destination. Instead, it only avoids obstacles locally as it follows the path. Roughly speaking, it is the difference between you planning a path from your home to the mall, and avoiding the possible people and obstacles you may find along the path. As such, if we set too many walls up, the Vehicle might have a hard time finding the target: for instance, if the Agent ends up facing a dead-end in a U-shaped object, it may not be able to get out. Try a few different wall setups and see how your agent performs.
Adding a custom layer
We now add a custom layer to the Obstacles object:
- To add a new layer, navigate to Edit | Project Settings:
Figure 6.8 – The Project Settings
- Go to the Tags and Layer section.
- Assign the name Obstacles to User Layer 8.
- We then go back to our cube entity and set its Layers property to Obstacles:
Figure 6.9 – Creating a new layer
- When we use raycasting to detect obstacles, we check for those entities, but only on this layer. This way, the physics system can ignore objects hit by a ray that are not an obstacle, such as bushes or vegetation:
Figure 6.10 – Assigning our new layer
- For larger projects, our game objects probably already have a layer assigned to them. As such, instead of changing the object's layer to Obstacles, we would instead make a list of layers for our cube entity to use when detecting obstacles. We will talk more about this in the next section.
INFO
In games, we use layers to let cameras render only a part of the scene or have lights illuminate only a subset of the objects. However, layers can also be used by raycasting to ignore colliders selectively or to create collisions. You can learn more about this at https://docs.unity3d.com/Manual/Layers.html.
Obstacle avoidance
Now, it is time to code the script that makes the cube entity avoid the walls. As usual, we first initialize our entity script with the default properties. Here, we also draw GUI text in our OnGUI method. Let's take a look at the following code in the VehicleAvoidance.cs file:
using UnityEngine;
public class VehicleAvoidance : MonoBehaviour {
public float vehicleRadius = 1.2f;
public float speed = 10.0f;
public float force = 50.0f;
public float minimumDistToAvoid = 10.0f;
public float targetReachedRadius = 3.0f;
//Actual speed of the vehicle
private float curSpeed;
private Vector3 targetPoint;
// Use this for initialization
void Start() {
targetPoint = Vector3.zero;
}
void OnGUI() {
GUILayout.Label("Click anywhere to move the vehicle
to the clicked point");
}
Then, in the Update method, we update the Agent entity's position and rotation based on the direction vector returned by the AvoidObstacles method:
void Update() {
//Vehicle move by mouse click
var ray = Camera.main.ScreenPointToRay(
Input.mousePosition);
if (Input.GetMouseButtonDown(0) &&
Physics.Raycast(ray, out var hit, 100.0f)) {
targetPoint = hit.point;
}
//Directional vector to the target position
Vector3 dir = (targetPoint - transform.position);
dir.Normalize();
//Apply obstacle avoidance
AvoidObstacles(ref dir);
. . . .
}
The first thing we do in the Update method is to retrieve the position of the mouse-click. Then, we use this position to determine the desired target position of our character. To get the mouse-click position, we shoot a ray from the camera in the direction it's facing. Then, we take the point where the ray hits the ground plane as the target position.
Once we get the target position, we can calculate the direction vector by subtracting the current position vector from the target position vector. Then, we call the AvoidObstacles method passing this direction to it:
public void AvoidObstacles(ref Vector3 dir) {
//Only detect layer 8 (Obstacles)
int layerMask = 1 << 8;
//Check that the vehicle hit with the obstacles
//within it's minimum distance to avoid
if (Physics.SphereCast(transform.position,
vehicleRadius, transform.forward, out var hit,
minimumDistToAvoid, layerMask)) {
//Get the normal of the hit point to calculate
//the new direction
Vector3 hitNormal = hit.normal;
//Don't want to move in Y-Space
hitNormal.y = 0.0f;
//Get the new directional vector by adding
//force to vehicle's current forward vector
dir = transform.forward + hitNormal * force;
}
}
The AvoidObstacles method is also quite simple. Note that we use another very useful Unity physics utility: a SphereCast. A SphereCast is similar to the Raycast but, instead of detecting a collider by firing a dimensionless ray, it fires a chunky sphere. In practice, a SphereCast gives width to the Raycast ray.
Why is this important? Because our character is not dimensionless. We want to be sure that the entire body of the character can avoid the collision.
Another thing to note is that the SphereCast interacts selectively with the Obstacles layer we specified at User Layer 8 in the Unity3D Tag Manager. The SphereCast method accepts a layer mask parameter to determine which layers to ignore and consider during raycasting. Now, if you look at how many layers you can specify in Tag Manager, you'll find a total of 32 layers.
Therefore, Unity3D uses a 32-bit integer number to represent this layer mask parameter. For example, the following would represent a zero in 32 bits:
0000 0000 0000 0000 0000 0000 0000 0000
By default, Unity3D uses the first eight layers as built-in layers. So, when you use a Raycast or a SphereCast without using a layer mask parameter, it detects every object in those eight layers. We can represent this interaction mask with a bitmask, as follows:
0000 0000 0000 0000 0000 0000 1111 1111
In this demo, we set the Obstacles layer as layer 8 (9th index). Because we only want to detect obstacles in this layer, we want to set up the bitmask in the following way:
0000 0000 0000 0000 0000 0001 0000 0000
The easiest way to set up this bitmask is by using the bit shift operators. We only need to place the on bit, 1, at the 9th index, which means we can just move that bit eight places to the left. So, we use the left shift operator to move the bit eight places to the left, as shown in the following code:
int layerMask = 1<<8;
If we wanted to use multiple layer masks, say, layer 8 and layer 9, an easy way would be to use the bitwise OR operator, as follows:
int layerMask = (1<<8) | (1<<9);
INFO
You can also find a good discussion on using layer masks on Unity3D's online resources. The question and answer site can be found at http://answers.unity3d.com/questions/8715/how-do-i-use-layermasks.html.
Once we have the layer mask, we call the Physics.SphereCast method from the current entity's position and in the forward direction. We use a sphere of radius vehicleRadius (make sure that is big enough to contain the cubic vehicle in its entirety) and a detection distance defined by the minimumDistToAvoid variable. In fact, we want to detect only the objects that are close enough to affect our movement.
Then, we take the normal vector of the hit ray, multiply it with the force vector, and add it to the current direction of the entity to get the new resultant direction vector, which we return from this method:
Figure 6.11 – How our cube entity avoids a wall
Then, in the Update method, we use this new direction to rotate the AI entity and update the position according to the speed value:
void Update () {
//...
//Don't move the vehicle when the target point is
//reached
if (Vector3.Distance(targetPoint,
transform.position) < targetReachedRadius)
return;
//Assign the speed with delta time
curSpeed = speed * Time.deltaTime;
//Rotate the vehicle to its target directional
//vector
var rot = Quaternion.LookRotation(dir);
transform.rotation =
Quaternion.Slerp(transform.rotation, rot, 5.0f *
Time.deltaTime);
//Move the vehicle towards
transform.position += transform.forward * curSpeed;
transform.position = new Vector3(
transform.position.x, 0, transform.position.z);
}
Now, we only need to attach this new script to the Vehicle object (this can be a simple cube as in the previous example). Remember that this new script needs to replace the VehicleFollowing script we implemented in the previous section.
Figure 6.12 – Properties of our Vehicle Avoidance script
If everything is correct, you should be able to see the vehicle navigate across the plane around the obstacles without any trouble. As usual, play with the Inspector parameters to tweak the vehicle behavior.
Summary
In this chapter, we set up two scenes and studied how to build path-following agents with obstacle avoidance behavior. We learned about the Unity3D layer feature and how to use Raycasts and SphereCasts against a particular layer selectively. Although these examples were simple, we can apply these simple techniques to various scenarios. For instance, we can set up a path along a road. We can easily set up a decent traffic simulation using some vehicle models combined with obstacle avoidance behavior. Alternatively, you could just replace them with biped characters and build a crowd simulation. You can also combine them with some finite state machines to add more behaviors and make them more intelligent.
The simple obstacle avoidance behavior that we implemented in this chapter doesn't consider the optimal path to reach the target position. Instead, it just goes straight to that target, and only if an obstacle is seen within a certain distance does it try to avoid it. For this reason, it's supposed to be used among moving or dynamic objects and obstacles.
In the following chapter, we'll study how to implement a pathfinding algorithm, called A*, to determine the optimal path before moving, while still avoiding static obstacles.