This is part 25 of a tutorial series about hexagon maps. The previous part was about regions and erosion. This time we'll add moisture to the land.

This tutorial is made with Unity 2017.3.0.

Clouds

Up to this point our map generation algorithm only adjusts the elevation of cells. The biggest difference between cells is whether they are submerged or not. While we also set different terrain types, that's just a simple visualization of elevation. A better way to assign terrain types would be by taking the local climate into consideration.

The climate of earth is a very complex system. Fortunately, we don't have to create a realistic climate simulation. All we need is something that looks natural enough. The most important aspect of the climate is the water cycle, because flora and fauna need liquid water to survive. Temperature is very important too, but we'll focus on water this time, effectively keeping the global temperature constant, while varying wetness.

The water cycle describes how water moves through the environment. Put simply, waterbodies evaporate, which leads to clouds, which produce rain, which flows back to the waterbodies. There's much more to it than that, but simulating those steps might already be enough to produce a seemingly natural distribution of water across our map.

Visualizing Data Before we get to the actual simulation, it would be useful if we could directly see the relevant data. For this purpose, we'll adjust our Terrain shader. Give it a toggle property so we can switch it into data-visualization mode, displaying raw map data instead of the usual terrain textures. This is done via a float property with a toggle attribute, specifying a keyword. That will make it show up as a checkbox in the material inspector, which controls whether the keyword is set. The actual name of the property doesn't matter, only the keyword, for which we'll use SHOW_MAP_DATA. Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _GridTex ("Grid Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Specular ("Specular", Color) = (0.2, 0.2, 0.2) _BackgroundColor ("Background Color", Color) = (0,0,0) [Toggle(SHOW_MAP_DATA)] _ShowMapData ("Show Map Data", Float) = 0 } Toggle for showing map data. Add a shader feature to enable support for the keyword. #pragma multi_compile _ GRID_ON #pragma multi_compile _ HEX_MAP_EDIT_MODE #pragma shader_feature SHOW_MAP_DATA We'll make it possible to display a single float value, just like the other terrain data. To make this possible, add a mapData field to the Input structure, when the keyword is defined. struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility; #if defined(SHOW_MAP_DATA) float mapData; #endif }; In the vertex program, we'll use the Z channel of the cell data to fill mapData , interpolated between cells as usual. void vert (inout appdata_full v, out Input data) { … #if defined(SHOW_MAP_DATA) data.mapData = cell0.z * v.color.x + cell1.z * v.color.y + cell2.z * v.color.z; #endif } When map data should be shown, directly use it as the fragment's albedo, instead of the normal color. Multiply it with the grid, so the grid can still be enable when visualizing data. void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … o.Albedo = c.rgb * grid * _Color * explored; #if defined(SHOW_MAP_DATA) o.Albedo = IN.mapData * grid; #endif … } To actually get any data to the shader, we have to add a method to HexCellShaderData to put something in the blue channel of its texture data. The data is a single float value, clamped to the 0–1 range. public void SetMapData (HexCell cell, float data) { cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255); enabled = true; } However, this approach interferes with our exploration system. A value ot 255 for the blue data component is used to indicate that a cell's visibility is in transition. To keep this working, we have to use the byte value 254 as the maximum. Note that unit movement will wipe out the map data, but that's fine as we only use it for debugging map generation. cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f ) : (byte) 254 ); Add a method with the same name to HexCell as well, which passes the request on to its shader data. public void SetMapData (float data) { ShaderData.SetMapData(this, data); } To test whether this works, adjust HexMapGenerator.SetTerrainType so it sets each cell's map data. Let's visualize elevation, converted from an integer to a float in the 0–1 range. This is done by subtracting the elevation minimum from the cell's elevation, then dividing that by the elevation maximum minus the minimum. Ensure that it is a float division. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( (cell.Elevation - elevationMinimum) / (float)(elevationMaximum - elevationMinimum) ); } } You should now be able to switch between the normal terrain and data visualization, by toggling the Show Map Data checkbox of the Terrain material asset. Map 1208905299, normal terrain and elevation visualization.

Creating a Climate To simulate a climate, we have to keep track of the climate's data. As our map consists of discrete cells, each cell has its own local climate. Create a ClimateData struct to contain all the relevant data. While we could add this data to the cells themselves, we're only going to use it when generating the map. So we'll store it separately instead. This means that we can define this struct inside HexMapGenerator , just like MapRegion . We'll begin by only tracking clouds, which we can do with a single float field. struct ClimateData { public float clouds; } Add a list to keep track of the climate data for all cells. List<ClimateData> climate = new List<ClimateData>(); Now we need a method to create the map's climate. It should start with clearing the climate list, then adding one item for each cell. The initial climate data is simply zero, which we get via the default constructor of ClimateData . void CreateClimate () { climate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); } } The climate has to be created after the land has been eroded and before the terrain types are set. In reality, erosion is mostly caused by the movement of air and water, which is part of the climate, but we're not going to simulate that. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); SetTerrainType(); … } Change SetTerrainType so we can see the cloud data instead of the cell elevation. Initially, that will look like a black map. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( climate[i].clouds ); } }

Evolving Climate The first step of our climate simulation is evaporation. How much water should evaporate? Let's control that with a slider. A value of 0 means no evaporation at all, while 1 means maximum evaporation. We'll use 0.5 as the default. [Range(0f, 1f)] public float evaporation = 0.5f; Evaporation slider. Let's create another method specifically to evolve the climate of a single cell. Give it the cell's index as a parameter and use it to retrieve the relevant cell and its climate data. If the cell is underwater, then we're dealing with a waterbody, which should evaporate. We'll immediately turn the vapor into clouds – ignoring dew points and condensation – so directly add the evaporation to the cell's clouds value. Once we're done, copy the climate data back to the list. void EvolveClimate (int cellIndex) { HexCell cell = grid.GetCell(cellIndex); ClimateData cellClimate = climate[cellIndex]; if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } climate[cellIndex] = cellClimate; } Invoke this method in CreateClimate , for every cell. void CreateClimate () { … for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } } Doing this just once isn't sufficient. To create a complex simulation, we have to evolve the cell climates multiple times. The more often we do this, the more refined the result will be. We'll simply pick a fixed amount, let's use 40 cycles. for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } } Because right now we're only increasing the clouds above submerged cells, we end up with black land and white waterbodies. Evaporation above water.

Cloud Dispersal Clouds don't stay in one place forever, especially not when more and more water keeps evaporating. Pressure differences cause air to move, manifesting as wind, which makes the clouds to move as well. If there isn't a dominant wind direction, on average the clouds of a cell will disperse in all directions equally, ending up in the cell's neighbors. As new clouds will be generated in the next cycle, let's distribute all the clouds that are currently in the cell among its neighbors. So each neighbor gets one-sixth of the cell's clouds, after which the local drop to zero. if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float cloudDispersal = cellClimate.clouds * (1f / 6f); cellClimate.clouds = 0f; climate[cellIndex] = cellClimate; To actually add the clouds to the neighbors, loop through them, retrieve their climate data, increase their clouds value, and copy it back to the list. float cloudDispersal = cellClimate.clouds * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; climate[neighbor.Index] = neighborClimate; } cellClimate.clouds = 0f; Dispersing clouds. This produces an almost white map. That's because each cycle all underwater cells add more clouds to the global climate. After the first cycle, the land cells next to water now have some clouds to disperse as well. This process compounds until most of the map is covered with clouds. In the case of map 1208905299 with default settings, only the interior of the large northeast landmass hasn't been fully covered yet. Note that our waterbodies can generate an infinite amount of clouds. The water level is not part of our climate simulation. In reality, waterbodies persist only because water flows back to them at about the same rate that they evaporate. So we're only simulating a partial water cycle. This is fine, but we should be aware that this means that the longer the simulation runs, the more water gets added to the climate. Right now, the only loss of water happens at the edge of the map, where dispersed clouds are lost to non-existing neighbors. You can see the loss of water at the top of the map, especially the cells at the top right. The last cell has no clouds at all, because it was the last one to evolve. It hasn't received any clouds from a neighbor yet. Shouldn't all cell climates evolve in parallel? Yes, that would produce the most consistent simulation. Right now, due to the cell order, clouds get distributed towards the north and east across the entire map in a single cycle, but only a single step towards the south and west. However, this asymmetry gets smoothed out over 40 cycles. It's only really obvious at the edge of the map. We'll switch to parallel evolution later.