This is part 26 of a tutorial series about hexagon maps. The previous part added a partial water cycle to our map generation algorithm. This time we'll supplement it with rivers and temperature and assign more interesting biomes to cells.

This tutorial is made with Unity 2017.3.0p3.

Generating Rivers

Rivers are a consequence of the water cycle. Basically, they're formed by runoff that dug a channel via erosion. This suggests that we could add rivers based on a cell's runoff. However, this won't guarantee that we get anything that looks like actual rivers. Once we start a river, it should keep flowing as far as it can, potentially across many cells. This doesn't fit our water cycle simulation, which operates on cells in parallel. Also, you'd typically want control over how many rivers there are on a map.

Because rivers are so different, we're going to generate them separately. We'll use the results of the water cycle simulation to determine where to place rivers, but we won't have rivers affect the simulation in return.

Sometimes the flow of a river is wrong? There's a bug in our TriangulateWaterShore method, which rarely manifests itself. this happens at a river end point, after reversing the direction of the flow. The problem is that we only checked whether the current direction matching the incoming river direction. This goes wrong when we're dealing with the start of a river. The solution is to also check whether the cell actually has an incoming river. I've put this fix in the Rivers tutorial as well. void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } … }

High and Wet On our maps, a cell has either a river or it doesn't. They also cannot branch or merge. In reality, rivers are far more flexible than that, but we'll have to make do with this approximation, representing larger rivers only. The most important fact that we have to determine is where a large river starts, which we'll have to pick at random. Because rivers require water, the river's origin has to be in a cell that has a lot of moisture. But that's not enough. Rivers flow downhill, so ideally the origin has a high elevation as well. The higher a cell is above the water level, the better a candidate it is for a river origin. We can visualize this as map data, by dividing a cell's elevation by the maximum elevation. To make this relative to the water level, subtract it from both elevations before dividing. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … float data = (float)(cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData( data ); } } Moisture and elevation above water. Default large map 1208905299. The best candidates are those cells that have both high moisture and high elevation. We can combine these criteria by multiplying them. The result is the fitness or weight for river origins. float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); Weights for river origins. Ideally, we'd use these weights to bias the random selection of an origin cell. While we could construct a properly weighed list and pick from it, that's not trivial and slows down the generation process. We can make do with a simpler classification of importance, distinguishing between four levels. Prime candidates have weights above 0.75. Good candidates have weights above 0.5. Still acceptable candidates have weights above 0.25. All other cells are disqualified. Let's visualize what that looks like. float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (data > 0.75f) { cell.SetMapData(1f); } else if (data > 0.5f) { cell.SetMapData(0.5f); } else if (data > 0.25f) { cell.SetMapData(0.25f); } // cell.SetMapData(data); River origin weight categories. With this classification scheme, we'll likely end up with rivers originating from the higher and wetter areas of the map. But it's still possible for rivers to form in somewhat lower or drier areas as well, providing variety. Add a CreateRivers method that fills a list of cells using these criteria. Acceptable cells are added to this list once, good cells twice, and prime candidates four times. Cell that are underwater are always disqualified, so we can skip checking them. void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (cell.IsUnderwater) { continue; } ClimateData data = climate[i]; float weight = data.moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (weight > 0.75f) { riverOrigins.Add(cell); riverOrigins.Add(cell); } if (weight > 0.5f) { riverOrigins.Add(cell); } if (weight > 0.25f) { riverOrigins.Add(cell); } } ListPool<HexCell>.Add(riverOrigins); } This method must be invoked after CreateClimate , so we have the moisture data available. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); CreateRivers(); SetTerrainType(); … } With our classification complete, we can get rid of the map data visualization of it. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … // float data = // moisture * (cell.Elevation - waterLevel) / // (elevationMaximum - waterLevel); // if (data > 0.6f) { // cell.SetMapData(1f); // } // else if (data > 0.4f) { // cell.SetMapData(0.5f); // } // else if (data > 0.2f) { // cell.SetMapData(0.25f); // } } }

River Budget How many rivers are desirable? This should be configurable. As rivers have varying length, it makes most sense to control this with a river budget, which states how much land cells should contain a river. Let's express this as a percentage with a maximum of 20% and a default of 10%. Like the land percentage, this is a target amount, not a guarantee. We might end up with too few candidates or rivers that are too short to cover the desired amount of land. That's why the maximum percentage shouldn't be too high. [Range(0, 20)] public int riverPercentage = 10; River percentage slider. To be able to determine the river budget expressed as an amount of cells, we have to remember how many land cells were generated in CreateLand . int cellCount , landCells ; … void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); landCells = landBudget; for (int guard = 0; guard < 10000; guard++) { … } if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); landCells -= landBudget; } } Inside CreateRivers , the river budget can now be computed just like we do in CreateLand . void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { … } int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); ListPool<HexCell>.Add(riverOrigins); } Next, keep picking and removing random cells from the origin list, as long as we have budget and origins remaining. Also log a warning in case we failed to use up the budget. int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); } if (riverBudget > 0) { Debug.LogWarning("Failed to use up river budget."); } Besides that, also add a method to actually create a river. It needs the origin cell as its parameter and should return the river's length once it's done. Begin with a method stub that only returns a length of zero. int CreateRiver (HexCell origin) { int length = 0; return length; } Invoke this method at the end of the loop that we just added to CreateRivers , using it to decrease the remaining budget. Make sure that we only create a new river if the chosen cell doesn't already have one flowing through it. while (riverBudget > 0 && riverOrigins.Count > 0) { … if (!origin.HasRiver) { riverBudget -= CreateRiver(origin); } }

Flowing Rivers Having a river flow towards the sea or another waterbody seems straightforward. As we start at its origin, we immediately begin with a length of 1. After that, pick a random neighbor, flow into it, and increment the length. Keep doing this until we end up in an underwater cell. int CreateRiver (HexCell origin) { int length = 1 ; HexCell cell = origin; while (!cell.IsUnderwater) { HexDirection direction = (HexDirection)Random.Range(0, 6); cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } Haphazard rivers. The result of this naive approach is haphazardly placed river fragments, mostly because we end up replacing previously-generated rivers. It might even lead to errors, as we don't even check whether a neighbor actually exists. We have to loop through all directions and verify that we have a neighbor there. If so, add this direction to a list of potential flow directions, but only if that neighbor doesn't already have a river flowing through it. Then pick a random direction from that list. List<HexDirection> flowDirections = new List<HexDirection>(); … int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor || neighbor.HasRiver) { continue; } flowDirections.Add(d); } HexDirection direction = // (HexDirection)Random.Range(0, 6); flowDirections[Random.Range(0, flowDirections.Count)]; cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } With this new approach, it is possible that we end up with zero available flow directions. When that happens, the river cannot flow any further and we have to abort. If the length is equal to 1 at this point, it means that we cannot flow out of the origin cell, so there cannot be a river at all. In that case the river's length is zero. flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (flowDirections.Count == 0) { return length > 1 ? length : 0; } Preserved rivers.

Flowing Downhill We're now preserving already-created rivers, but we still end up with isolated river fragments. This happens because so far we've ignored elevation. Each time we make a river flow to a higher elevation, HexCell.SetOutgoingRiver correctly aborts the attempt, which leads to discontinuities in our rivers. So we also have to skip directions that would cause the river to flow upward. if (!neighbor || neighbor.HasRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } flowDirections.Add(d); Rivers flowing downhill. This eliminates many river fragments, although we still get a few. From this point, it's a matter of refinement to get rid of most unsightly rivers. To start with, rivers prefer to flow downhill as quickly as possible. It's not guaranteed that they take the shortest possible route, but likely. To simulate this, at downhill directions three extra times to the list. if (delta > 0) { continue; } if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } flowDirections.Add(d);

Avoiding Sharp Turns Besides preferring to go downhill, flowing water also has momentum. A river is more likely to go straight ahead or curve slowly than to make a sudden sharp turn. We can introduce this bias by keeping track of the river's last direction. If a potential flow direction doesn't deviate too much from this direction, add it once more to the list. This isn't an issue at the origin, so simply always add it again in that case. int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; HexDirection direction = HexDirection.NE; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } if ( length == 1 || (d != direction.Next2() && d != direction.Previous2()) ) { flowDirections.Add(d); } flowDirections.Add(d); } if (flowDirections.Count == 0) { return length > 1 ? length : 0; } // HexDirection direction = direction = flowDirections[Random.Range(0, flowDirections.Count)]; cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } This makes it far less likely for zigzag rivers to appear, which are unsightly. Fewer sharp turns.

Merging Rivers Sometimes a river ends up flowing right next to the origin of a previously-created river. Unless that river origin it at a higher elevation, we could decide to make the new river flow into the old one. The result is a single longer river, instead of two nearby shorter ones. To do this, only skip a neighbor if it has an incoming river, or if it's the origin of the current river. Then after we've established that it isn't an uphill direction, check whether there is an outgoing river. If so, we've found an old river origin. Because this is fairly rare, don't bother checking for other potential neighbor origins and immediately merge the rivers. HexCell neighbor = cell.GetNeighbor(d); // if (!neighbor || neighbor.HasRiver) { // continue; // } if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } if (neighbor.HasOutgoingRiver) { cell.SetOutgoingRiver(d); return length; } Rivers before and after merging.

Keeping Distance Because good quality origin candidates tend to cluster together, we end up with clusters of rivers. Also, we can end up with rivers originating right next to a waterbody, resulting in single-step rivers. We can spread out the origins by disqualifying those that are adjacent to a river or waterbody. Do this by looping through the neighbors of the chosen origin in CreateRivers . If we find an offending neighbor, the origin isn't valid and we should skip it. while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); if (!origin.HasRiver) { bool isValidOrigin = true; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = origin.GetNeighbor(d); if (neighbor && (neighbor.HasRiver || neighbor.IsUnderwater)) { isValidOrigin = false; break; } } if (isValidOrigin) { riverBudget -= CreateRiver(origin); } } While rivers can still end up flowing next to each other, they now tend to cover a larger area. Without and with keeping distance.

Ending with a Lake Not all rivers make it to a waterbody. Some get struck in a valley or blocked by other rivers. This isn't a big problem, because there are many real rivers that also seem to disappear. This could happen for example because they flow underground, because they diffuse into a swampy area, or because they dry up. Our rivers cannot visualize this, so they simply end. Having said that, we should try to minimize such occurrences. While we cannot merge rivers or make them flow uphill, we might be able make them end in a lake, which is more common and looks better. To do this, CreateRiver has to raise the water level of the cell when it gets stuck. Whether this is possible depends on the minimum elevation of that cell's neighbors. So keep track of this when investigating the neighbors, which requires a little code restructuring. while (!cell.IsUnderwater) { int minNeighborElevation = int.MaxValue; flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); // if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) { // continue; // } if (!neighbor) { continue; } if (neighbor.Elevation < minNeighborElevation) { minNeighborElevation = neighbor.Elevation; } if (neighbor == origin || neighbor.HasIncomingRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } … } … } If we're stuck, first check whether we're still at the origin. If so, simply abort the river. Otherwise, check whether all neighbors are at least as high as the current cell. If that is the case, then we can raise the water up to this level. This will create a single-cell lake, unless the cell's elevation is at the same level. If this is so, simple set the elevation to one below the water level. if (flowDirections.Count == 0) { // return length > 1 ? length : 0; if (length == 1) { return 0; } if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = minNeighborElevation; if (minNeighborElevation == cell.Elevation) { cell.Elevation = minNeighborElevation - 1; } } break; } River endings without and with lakes. River percentage at 20 in this case. Note that we can now end up with underwater cells above the water level used to generate the map, representing lakes above sea level.