AV-Racer Devlog (3): Designing the AI
Thursday, May 19, 2022
est. reading time: 9 minutes
As explained in the
previous article, the maps include
node and spline data for an AI line to follow. The line runs through the track and is meant to guide steering.
Having that, the first step to take is to figure out how to make the AI follow the line.
The nodes are in blue. Connecting them is the AI line.
The AI input can be summed up in two main categories: The first is steering, the second is gas and brake input.
I decided to tackle the steering first.
Steering
The first and most basic model I thought to implement was a node-oriented steering model. The idea is that a
simple vector is calculated from the car's current position to the nearest node on the track. Then an angle is
calculated between this vector and the car's longitudinal vector pointing forward. The resulting angle
determines how much
the car would steer left or right. The steering angle is then capped to 45 degrees.
There were a few prerequisite calculations to get this right. To know where the nearest node is, we would have
to loop over all nodes on track, and check where the nearest is. However, this
would mean the car would go to the nearest node regardless where it was on the track. So, if that nearest node
was on a nearby road parallel to the road the car is on, then the AI would drive itself into a wall; not
exactly ideal.
A better algorithm would have us know the current AI node the car is on. Then, relative to that, we get the next
node
on the track. This will guarantee the car doesn't try to cut the track to the wrong nodes, or even worse, start
driving backwards. To know what node we are on we can refer to what segment of the track we are on, and if we
are on the same segment as a certain node then we are at that node more or less. This works since track segments
are relatively small, as cerated with the map editor.
Getting the car's position:
Below is code from the game on how that is done:
float TriangleArea(v2 A, v2 B, v2 C)
{
return abs((A.x * (B.y - C.y) + B.x * (C.y - A.y) + C.x * (A.y - B.y)) / 2.0f);
}
bool PointInTriangle(v2 P, v2 A, v2 B, v2 C)
{
float A1 = TriangleArea(P, B, C);
float A2 = TriangleArea(P, C, A);
float A3 = TriangleArea(P, A, B);
float AA = TriangleArea(A, B, C);
return abs(A1 + A2 + A3 - AA) < 0.00001f;
}
bool PointInNodeSegment(v2 *P, tracknode *A, tracknode *B)
{
bool a = PointInTriangle(*P, A->L, A->R, B->L);
bool b = PointInTriangle(*P, A->R, B->L, B->R);
return a || b;
}
....
static void UpdateCarOnTrack(int p)
{
v2 P = G->Car[p].P;
bool is_on_track = false;
for (int t = 0; t < G->Track.T_NodeCount - 1; t++)
{
if (PointInNodeSegment(&P, &G->Track.T_Nodes[t], &G->Track.T_Nodes[t + 1]))
{
is_on_track = true;
G->Car[p].trackpos = t;
}
}
G->Car[p].ontrack = is_on_track;
}
The function
UpdateCarOnTrack()
above gets the current track position of the car and checks if it is on the track. That information is useful for other effects like the car's grip and emulation of a dirt/ asphalt surface. The track position information also helps sorting car positions on the live leadboard according to relative track positions.
The
PointInNodeSegment()
runs a basic algorithm where every two track nodes are taken and the car position is checked whether it is between them. Since each track node has a left and right points defining the road, we can check if the position is within either of the two triangles formed between the four points of the two node groups. This is done using a method that determines if a point is inside a triangle
PointInTriangle()
which calculates the areas formed between the point in question and all two pairs of points defining a triangle. When comparing areas one could predict if the point is inside, outside, or on the triangle. I use
< 0.00001f
to account for floating point calculation errors. The method isn't the most efficient but the calculations weren't many per frame (~250 track nodes per track, and a maximum of 10 cars = ~2.5k calculations per frame. It was fine). Here's how it works:
Triangles (1) and (2) in the picture are formed between (A)'s and (B)'s respective left and right node points. The two triangles form a 'Segment'. In this case, the car is on triangle (1) so it is between the nodes (A) and (B), so it is
at the track node (A). Determining which triangle it is on is therefore irrelevant.
Now we have an algorithm that can determine what part of the track a point is on, we can use it on the AI nodes. Knowing where the AI nodes are can then help us know where the nearest one to the car is, relative to the car's position. It would be wasteful, however, to run this algorithm every frame on all the AI nodes like we do on cars. The AI nodes are predetermined and static, unlike moving cars. So, it is best to precalculate the data of where every AI node on the track is. We can do this at the track loading phase and store that data statically for reference.
1- Spidey Model v1:
Now that we have the data we need we can use the AI steering algorithm mentioned at the start and apply it to the car. The result looks as follows :
Basically every AI car has a PID controller struct which saves information like the last error, time,
accumulative integral and the three constant coefficients we adjust through testing. (usually Ki and Kd are half
of Kp). We input the "error" which in this case is the distance between the projection the nearest AI line
segment. We
then use the output to adjust steering. This significantly smoothens the AI steering behavior.
Throttle and Brake
If we look at how we handled steering, we can see that the system relies on a premade AI racing line which
represents the steering data. It then uses that as a reference to adjust the car's angle. Throttle and brake
input, however, does not have an AI line of their own, as that information cannot be drawn in the editor.
Generating correct throttle and brake input was also not a realistic option as it required much more complex AI
design to handle predicting optimum corner speeds on the go. So in order to make an AI "speed line", we could
just record the car's speeds at each node. The problem turned out to be much simpler than I initially
anticipated.
Since the whole game takes place in premade tracks, there is really no real reason to create any "smart" AI
pathfinding algorithms. The AI will just follow the AI line and drive at the speed it was recorded at. So I
implemented a record functionality and an array of AI node speeds stored in the track metadata, each time I
create a
new track, I run the AI with it while controlling throttle and brake input myself while the game records the
speeds. That info then gets saved on the track file. When playing, a very
simple algorithm compares the current speed to the one recorded adjusts the car's inputs, producing very
usable results.
Putting all of this together, we get the following: