Hands-on Rust: Dijkstra's/A* questions

Excellent book. I like it very much. Just a few questions that occurred to me while reading the pathfinding section in Chapter 9 on page 171.

  1. On line 3, why is there a self.in_bounds wrapping can_enter_tile when can_enter_tile has a map bounds check

  2. On page 173, why do we use pathing distance? It is my understanding the Dijkstra maps do not use heuristics. Will we be using A* pathfinding at some point?

  3. Further related to that, I noticed the heuristic shown is a euclidean distance. Why is a slower heuristic used for 4-directional movement? It is my understanding that a straight line is always going to be shorter than a manhattan distance, thus causing an A* search to take longer. Not to mention that square root call. Same idea for those players wanting 8-directional movement. Why not a diagonal distance instead of a euclidean.

I also looked at bracket-lib and have a related question to the A*. Nice, by the way, with the use of the min-heap and hashmaps. I’m just curious as to why the closed_list would contain an f cost conditional.

if should_add && self.closed_list.contains_key(&idx) && self.closed_list[&idx] < s.f {

My understanding is that the closed list is just used to check where the algorithm has traversed. I can’t imagine a situation where a closed node would be revisited when using a consistent heuristic. Is that why it is there, in case someone wants to use an inconsistent heuristic.

I was also wondering why the call to add_successor adds the parents f cost to the successor instead of parents’s g cost.
.for_each(|s| self.add_successor(q, s.0, s.1 + q.f, map));

Thanks. Once again, I love this book and am looking forward to your responses.

1 Like

Glad you are enjoying the book! I do recommend looking at the Github source for the A* implementation; it includes a couple of fixes (that will be live in the crate very soon) and performs quite a bit better. Search for “red blob games” on the topic; the author gave me a lot of help with my path-finding code!

In answer to your questions:

  1. The in_bounds trait is used by several of bracket-lib’s functions, in particular field of view. When the FOV code casts its light paths, it uses in_bounds to check that it isn’t trying to test a tile that is outside of the map. It’s not so useful in Dijkstra and A*, because get_available_exits hopefully won’t return invalid tiles - but field of view assumes a traditional grid, and doesn’t maintain an exit list. The trait default isn’t quite the same as the implementation in the book (handling of zero tiles), so it has to be overridden to not show bugs. (The Github FOV code is also improved, and will be going live very soon).

  2. The book code doesn’t really use the distance, but it’s good to have. If you add diagonal movement, you want to add in diagonal costs of about 1.4 (otherwise pathing functions will dance diagonally because it looks like the shorter path). Dijkstra doesn’t traditionally use pathing costs, but it can - you can get pretty decent results including things like “this swampy tile type costs more to traverse” and keep the same basic functionality but tend towards avoiding the swampy tiles in generated paths. The book doesn’t get to A* (I thought it would, but that content didn’t make the cut).

  3. I went with the simple Euclid heuristic because it’s the easiest to explain (and the chapter is mostly teaching you to consume traits and benefit from library functions; the game is the teaching vehicle there). There’s some example code in the bracket-lib pathing section playing with different heuristics. Manhattan can give some interesting behavior - because it’s only counting horizontal + vertical distance, the paths it picks to navigate obstacles can look a bit unnatural for someone on foot (and look very natural for someone driving a vehicle).

So why retain the square-root in the distance heuristic? When you’re comparing distances, it generally doesn’t matter if you have distance or distance^2 so long as you compare like-with-like. It’s actually something I agonized over a bit, and decided to go with teaching the more “natural” approach. I did a fair amount of testing behind-the-scenes before I came to that conclusion:

  • When I got started with game development (on an 8-bit BBC Micro) a single square-root call could mean missing a few frames. Even on a 486DX (math co-processor onboard), the impact was pretty painful. So you either used d^2 or a fast approximation function. When I got started with bracket-lib, I was really surprised by just how fast square roots are on modern hardware. Even on a core2, a square root call is only 9-69 cycles. On recent i5/i7/i9, the call is typically vectorized and is ridiculously fast (even the non-approximate call is only a little slower than a division now). On an i3, sqrt benchmarked at 10 million sqrt calls in 870ms. So when I did a bunch of pathing benchmarks, I found that eliding the sqrt really didn’t change much in terms of performance!
  • (Aside: An interesting side-effect of the speed of modern floating point ops is that a vector line using floats can actually outperform integer Bresenham on some systems now!)
  • My page count is a little tight, so I didn’t want to expend the extra ink explaining a relatively minor optimization.
  • You can get some funky results using distance^2 on pathing functions that total up cost and also compare to distance-to-exit (A*, I’m looking at you!). When you are listing exits with d^2, the natural cost to move in a cardinal direction is 1^2 (1*1) - or 1. The d^2 for moving a single tile is also 1. But when you are 4 tiles from your exit, the cumulative “add distance^2 from exit” is 4 - but d^2 is actually 4^2 = 16. So the algorithm goes somewhat haywire, because you aren’t comparing like with like anymore.

So why check that the closed list entry has a lower total cost than the current step? Its partly there because I copied it from the algorithm description - but it can be useful. Remember that A* traverses any graph, not just a tile map - and it can run into tiles that teleport you somewhere else on the map, or graph nodes that don’t provide sensible linear movement (you can build one-way nodes if you want!). So it is possible to come up with a map that requires some back-tracking. 99.9% of the time, that check isn’t needed.

Likewise, it’s adding the f cost because that was in the original algorithm description I implemented. Most of the time, g would work - but going with the total cost incurred so far discourages back-tracking. If you are just considering the one-hop cost of the parent, you have basically fallen back to a breadth-first search and A* won’t be so pedantic about following what appears to be the shortest total path.


Some examples to try to illustrate this:

Here’s an A-star traversal using regular Pythagoras:
image

Here’s an A-star traversal using Pythagoras squared:
image

The difference between “one hop^2” and “total distance^2” becomes a problem and you get a sub-optimal path.

Here’s A-star in Manhattan mode:
image

In this case, the heuristic favors trying to shorten the vertical distance, so it looks like a better option to take the odd route.

Benchmarking
The benchmark built into bracket-pathfinding randomly generates an 80x20 tile map and uses A* to move across it. Execution times (over 10k iterations with a 3s warmup, on 4-year old core i7):

Pythagoras: 812.80 us (best), 818.56 us (average), 824.65 us (worst)
Pythagoras Squared: 758.92 us (best), 764.96 us (average), 771.61 us (worst)
Manhattan: 823.16 us (best), 827.57 us (average), 832.59 us (worst)

Edit: These times include randomly generating the map!

So in the grand scheme of things, there’s a tiny improvement for not running the square roots on distance: 53.6 us. Overall, that’s not really enough to notice a difference unless you are doing a lot of pathfinding per frame. You have 16,700 us to play with in a frame at 60 FPS, so unless you are recalculating a lot of paths on every tick you really don’t need to worry too much.

Some optimizations tips for the times you do care about 818us

From working on various games over the years, I’ll throw in a little optimization advice on pathfinding:

  • You generally don’t need to run it on every frame. If an entity needs to path to a static goal (for example, a dwarf finding his pick in Dwarf Fortress) you can run the A* once and store the path. Then each time the dwarf moves - check to see if the next step on the path is ok. If it is, keep following the path. If it isn’t, try to make a new one.
  • Kyzrati (of Cogmind fame) is very fond of an optimization that runs a simple line-of-sight test before calling the full A* system. If LoS exists, there’s your path!
  • Look into the “jump point” optimization for A*. It’s hard to implement in the most general sense (which is why its not in bracket-lib), but for some types of game it can make a huge difference. You basically store a list of rooms (great for maps that have them!), and each room stores a path to other rooms. So when you want to generate a path to an entity, you path to the nearest room (or just use the room ID if you are in it) and then path to the destination room. You can see this in action in RimWorld; the settlers sometimes take a slightly odd route to get to a room from which they can path to the destination room. (There’s a YouTube video about how it works, somewhere). It’s not always an optimal route, but it’s fast!

Hope that helped!

1 Like

Wow. What an awesome explanation. I think my IQ went up 2 points after reading that. By the way, I do think you struck the right balance between intro and technical in the book. These were just some additional curiosity questions. The optimization tips are great. I can see how line-of-site would be better to check before pathfinding, especially on open-world maps.

For clarity on the benchmarking though, I’ll draw an ascii 2x4 ascii grid to properly differentiate the heuristics. S means start, M means Manhattan, D means Diagonal Distance, and G is Goal. Hopefully, it shows up nicely here.

+--+--+--+--+
| S|M1|M2|M3|
+--+--+--+--+
|  |D1|D2| G|
+--+--+--+--+

Manhattan Distance = 4
Diagonal Distance = 3
Euclidean Distance = 2.23
Euclidean squared = 5

In 8-directional movement shown in the pictures, the only heuristic that predicts the distance is diagonal distance. Manhattan and euclidean squared overestimates the actual distance to goal. What I mean by overestimating is that the heuristic’s estimate is higher than the actual final path cost. What this means is that it will find a suboptimal path in the end, which is why you are getting the strange pathing on only those two. On the other hand, diagonal distance is spot on for 8-directional movement, as will be the pathing for euclidean distance which underestimates the actual distance. By underestimating, it will always be looking for the superoptimal path, which won’t lead you astray. So, the strange pathing, in my mind, is a misapplication of the heuristic. Redblob goes over those on his site. http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html#manhattan-distance
He suggests manhattan for orthogonal; diagonal distance for 8-way; euclidean for any way (non-grid), and never to use the squared due to scaling issues.

The actual benchmark I’d be looking for would be a diagonal distance and euclidean and would be willing to bet that you wouldn’t give the strange pathing. Blob shows the formula for it on his page. I predict the diagonal distance should be faster than the euclidean because it is a cheaper calculation. But who knows in practice. lol I think you could then safely take out the closed list f-cost checks also…unless you want to keep it open to the possibility that someone might use inconsistent heuristics. The portals you mention is also an interesting concept.

2 Likes

You got me curious (dangerous!), so I hacked in a diagonal distance heuristic.

fn distance2d_diagonal(start: Point, end: Point) -> f32 {
    i32::max(
        (start.x - end.x).abs(),
        (start.y - end.y).abs()
    ) as f32
}

Running the A* example using this heuristic gives a good path:
image

Benchmarking it:

Pythagoras: 812.22 us (best), 822.55 us (average), 835.07 us (worst)
Diagonal: 792.95 us (best), 801.07 us (average), 811.37 us (worst)

Edit: I took out the && self.closed_list[&idx] < s.f and benchmarked it. It gives a small improvement: 810.87 us (best), 819.79 us (average), 830.17 us (worst) - using the Pythagoras default in the benchmark. I’ll set my computer testing that over a few million maps overnight to make sure it doesn’t break anything before I push the change upstream.

So I think you’re onto a winner, albeit by a small margin. I’ll look to include that as an option in the next bracket-pathfinding crate release. Thanks!

1 Like

Sorry, I should have been clearer with my units. The units I was using for my diagram was “steps” for simple comparison of the distance calculations. But if you are going to apply it to A* then it should be in cost. Meaning whenever a diagonal step is taken, the savings should be considered. This is the difference between a step right and then a step down (2 cost) versus a diagonal step (1.45 cost). Blob shows the actual calculation with cost factored into the return value:

function heuristic(node) =
    dx = abs(node.x - goal.x)
    dy = abs(node.y - goal.y)
    return D * (dx + dy) + (D2 - 2 * D) * min(dx, dy)

where D is the straight move cost (1) and D2 is the diagonal movement cost (1.45). Notice that in a manhattan, the distance algorithm translates directly into the cost algorithm because all movements only cost 1.

It would be awesome if bracket-lib included diagonal distance and through the power of traits was able to figure out if the developer was choosing 4-way or 8-way movement and then just switches to the correct heuristic, manhattan or diagonal, automatically. Not only would it be faster, it would be safer because the developer wouldn’t accidently choose an overestimating heuristic and get the bad pathing.

1 Like