This project simulates the process of trailblazing by procedurally generating terrain maps and using a custom 3D A* algorithm.
Random terrain generation is a complicated problem with a variety of approaches. This project uses Perlin Noise combined with a gradient-based erosion simulation.
For simulating mountains with no erosion, I combine multiple layers of perlin noise to create a detailed height map.
I multiply each value in the image by a vertical scaling factor and treat each pixel as a height in order to make a 3D map.
The issue is that perlin noise is very smooth, and even with several octaves, mountains have a very "lumpy" appearance; real mountains have sharp peaks and ridges caused by erosion. This may seem like a cosmetic problem, but it is actually important for the functionality of the A* algorithm that the surface and peaks of the mountains are sharp rather than smooth. I will explain this in the A* cost section.
One option for erosion is hydraulic erosion, which is a way of simulating the actual path of water over time as it erodes sediment in the terrain. While this yields really incredible results, it is extremely slow and unwieldy to simulate hundreds of thousands of raindrops over time, and for an A* algorithm, it is probably not necessary. If you're interested in hydraulic erosion, this video by Sebastion Lague is really interesting, but overall seemed to be too complex for this project.
For an alternative to hydraulic erosion, I took inspiration from this video and this article to to implement what I call gradient-based erosion. The concept is pretty simple: in erosion, water carries sediment from high areas to low areas, taking away sediment from peaks and moving it downhill where it is deposited in flat layers. By this logic, steep areas of the map will be eroded more because rain carries more sediment away from them, while flat areas will be eroded less because sediment is deposited in them. To represent this mathematically, I use the gradient of the map, or the x and y derivatives with respect to z. By calculating the x and y derivatives, we can find the slope of each point in the noise map.
Once we have the slope,
This GIF shows the process of erosion, with the uneroded height map at the beginning and the eroded height map at the end.
Real mountains also have different types of terrains based on environmental factors. I simplified these different terrains into 5 biomes: water, ice, forest, plains, and rock. Each pixel was classified into one of these biomes. Pixels below a constant water level were moved up to that water level and assigned to water. Other pixels were classified using a combination of temperature and moisture. Temperature was defined as
| Biome | Ideal Moisture | Ideal Temperature |
|---|---|---|
| Ice | 0.5 | 0.1 |
| Forest | 0.6 | 0.5 |
| Plains | 0.3 | 0.6 |
| Rock | 0.3 | 0.3 |
Then, for each point, I can calculate a value that represents how well the point matches a certain biome by comparing the actual moisture to the ideal moisture considering some tolerance,
This image shows an environment with biomes: dark green represents forest, light green represents plains, blue represents water, white represents ice, and grey represents rocks. The colors are slightly blurred to look more realistic.
The goal of the A* algorithm is pretty simple: find the most optimal path between two points on the terrain map. Unfortunately, the "most optimal" part provides some complications: hikers don't always like to travel the absolute shortest path possible. For example, a hiker would go out of their way to avoid crossing water, but they also might take a much longer route to avoid having to go up a steep incline or cliff. The question of how to treat elevation gain and incline is the main complicating difference between 2D and 3D A*.
A lot of the A* for this algorithm is identical to the standard 2D approach: it is a graph-theory based approach where each node has both a
A typical A* neighbor function takes a position in the map and returns every available neighbor of that position, usually including diagonals and horizontal neighbors but excluding obstacles. This is mostly the same in 3D A*, the only difference being that we do have to consider elevation when deciding which neighbors are valid. I did this by calculating the vertical angle or incline between each point and comparing it an angle threshold, initially 45 degrees. This rules out any neighboring points that are too steep. There is also a flag for traversing water; initially, water is considered an obstacle, but some paths are impossible without crossing water, so if a path cannot be found, the algorithm allows traversing water.
This project allows diagonal motion and motion uphill, so Euclidean Distance is most practical. The typical Euclidean formula
The cost function weights different types of terrain differently. Given two points, I first calculate the raw distance between the two using the same equation defined in the heuristic function. I defined a dictionary of penalties for each type of terrain:
| Terrain | Penalty |
|---|---|
| Plains | 1 |
| Forest | 1.5 |
| Rocks | 2.5 |
| Ice | 4 |
| Water | 10 |
I find the penalties for both points and pick the higher penalty. For example, if point A is in the plains and point B is on the rocks, the penalty will be 2.5, but if point A is on the rocks and point B is in the plains, the penalty is still 2.5. I then multiply the raw distance by the penalty to weight for different types of terrain. This strategy leads to pretty good paths, but I noticed that maps of real trails are often not as direct as the paths I was generating.
An image of a real trail in McCarthy, Alaska.
An example path generated by my algorithm.
Real trails have switchbacks and sharp turns in order to make paths less steep, covering the same altitude in a slightly longer distance in order to make the hike easier. I found I could replicate this by adding an angle penalty into the cost function. I calculate the incline/angle of elevation between the two points,
A trail with no angle penalty.
A trail with a severe angle penalty. Aside from the path itself, it is useful to know that the grade (the elevation gain of the hike divided by the distance of the hike) is significantly lower, but the distance is significantly higher.
There are two main runnable files in the project:
- Run the
map_creator.pyfile to open the map creator interface. The interface features a variety of sliders to customize the map and biome generation, each labeled, on the left side of the window. In the bottom right corner is a small checkbox that will save the map to themapsfolder undermaps/map_{MAP INDEX}.pkl. In the pickle file is two NumPy arrays, the first being the height map and the second being the biome map. - Run the
run_trailblazer.pyfile to test the A* algorithm. The red cone represents the starting point of the path and the green represents the ending point. The red line in between is the calculated path. There are two checkboxes in the bottom right for changing the start and end points. When the start checkbox is selected, clicking anywhere on the map changes the starting point to that point. The same goes for the end point. You can control the amount of switchbacks by changing the angle penalty slider on the bottom. In the top right, there are four statistics: total distance ("dist"), altitude gained ("alt"), the maximum angle of elevation (kind of like the steepest point) on the path ("angle"), and the grade, which is the altitude divided by the total distance, or the slope, of the path ("grade").