Table of Contents

Check out Part 1, Part 2 and Part 3 of this series if you haven’t already. This is a continuation of those articles.

In the previous articles:

In this article (Part 4):

Biomes are a way of classifying terrain types. Our biome generator will be based on the ever popular Whittaker’s model, where biomes are classified based on precipitation and temperature. Since we already generated a heat map and a moisture map for our world, determining our biomes will be pretty easy. Whittaker’s classification scheme is represented in the following diagram:

We can identify different biome types based on a given temperature and moisture level. First, we can easily create a new enumeration that will store these biome types:

public enum BiomeType { Desert, Savanna, TropicalRainforest, Grassland, Woodland, SeasonalForest, TemperateRainforest, BorealForest, Tundra, Ice }

Then, we need to create a table that will tell us what biome type to use based on the temperature and humidity. We already have a HeatType, and a MoistureType. Each of these enumerations have 6 defined types. A table was created to match each of these types with Whittaker’s diagram, represented below:

In order to easily look up this data in code, we can easily just recreate this table as a two-dimensional array. This looks like the following:

BiomeType[,] BiomeTable = new BiomeType[6,6] { //COLDEST //COLDER //COLD //HOT //HOTTER //HOTTEST { BiomeType.Ice, BiomeType.Tundra, BiomeType.Grassland, BiomeType.Desert, BiomeType.Desert, BiomeType.Desert }, //DRYEST { BiomeType.Ice, BiomeType.Tundra, BiomeType.Grassland, BiomeType.Desert, BiomeType.Desert, BiomeType.Desert }, //DRYER { BiomeType.Ice, BiomeType.Tundra, BiomeType.Woodland, BiomeType.Woodland, BiomeType.Savanna, BiomeType.Savanna }, //DRY { BiomeType.Ice, BiomeType.Tundra, BiomeType.BorealForest, BiomeType.Woodland, BiomeType.Savanna, BiomeType.Savanna }, //WET { BiomeType.Ice, BiomeType.Tundra, BiomeType.BorealForest, BiomeType.SeasonalForest, BiomeType.TropicalRainforest, BiomeType.TropicalRainforest }, //WETTER { BiomeType.Ice, BiomeType.Tundra, BiomeType.BorealForest, BiomeType.TemperateRainforest, BiomeType.TropicalRainforest, BiomeType.TropicalRainforest } //WETTEST };

To make the lookup even easier, we will add a new function that will return the biome type of any tile. This part is quite simple, as each tile already has an associated heat and moisture type.

public BiomeType GetBiomeType(Tile tile) { return BiomeTable [(int)tile.MoistureType, (int)tile.HeatType]; }

This check is done for every single tile, and assigns all of our map’s biome zones.

private void GenerateBiomeMap() { for (var x = 0; x < Width; x++) { for (var y = 0; y < Height; y++) { if (!Tiles[x, y].Collidable) continue; Tile t = Tiles[x,y]; t.BiomeType = GetBiomeType(t); } } }

Great, so now all of the biomes are assigned. However, we have no way of seeing them yet. Our next step, is to assign a color for each Biome type. This will allow us to visually associate each biome region, so that we can represent them in an image. The colors I chose are as follows:

These color values are plugged into the TextureGenerator class, along with the Biome texture generation code:

//biome map private static Color Ice = Color.white; private static Color Desert = new Color(238/255f, 218/255f, 130/255f, 1); private static Color Savanna = new Color(177/255f, 209/255f, 110/255f, 1); private static Color TropicalRainforest = new Color(66/255f, 123/255f, 25/255f, 1); private static Color Tundra = new Color(96/255f, 131/255f, 112/255f, 1); private static Color TemperateRainforest = new Color(29/255f, 73/255f, 40/255f, 1); private static Color Grassland = new Color(164/255f, 225/255f, 99/255f, 1); private static Color SeasonalForest = new Color(73/255f, 100/255f, 35/255f, 1); private static Color BorealForest = new Color(95/255f, 115/255f, 62/255f, 1); private static Color Woodland = new Color(139/255f, 175/255f, 90/255f, 1); public static Texture2D GetBiomeMapTexture(int width, int height, Tile[,] tiles, float coldest, float colder, float cold) { var texture = new Texture2D(width, height); var pixels = new Color[width * height]; for (var x = 0; x < width; x++) { for (var y = 0; y < height; y++) { BiomeType value = tiles[x, y].BiomeType; switch(value){ case BiomeType.Ice: pixels[x + y * width] = Ice; break; case BiomeType.BorealForest: pixels[x + y * width] = BorealForest; break; case BiomeType.Desert: pixels[x + y * width] = Desert; break; case BiomeType.Grassland: pixels[x + y * width] = Grassland; break; case BiomeType.SeasonalForest: pixels[x + y * width] = SeasonalForest; break; case BiomeType.Tundra: pixels[x + y * width] = Tundra; break; case BiomeType.Savanna: pixels[x + y * width] = Savanna; break; case BiomeType.TemperateRainforest: pixels[x + y * width] = TemperateRainforest; break; case BiomeType.TropicalRainforest: pixels[x + y * width] = TropicalRainforest; break; case BiomeType.Woodland: pixels[x + y * width] = Woodland; break; } // Water tiles if (tiles[x,y].HeightType == HeightType.DeepWater) { pixels[x + y * width] = DeepColor; } else if (tiles[x,y].HeightType == HeightType.ShallowWater) { pixels[x + y * width] = ShallowColor; } // draw rivers if (tiles[x,y].HeightType == HeightType.River) { float heatValue = tiles[x,y].HeatValue; if (tiles[x,y].HeatType == HeatType.Coldest) pixels[x + y * width] = Color.Lerp (IceWater, ColdWater, (heatValue) / (coldest)); else if (tiles[x,y].HeatType == HeatType.Colder) pixels[x + y * width] = Color.Lerp (ColdWater, RiverWater, (heatValue - coldest) / (colder - coldest)); else if (tiles[x,y].HeatType == HeatType.Cold) pixels[x + y * width] = Color.Lerp (RiverWater, ShallowColor, (heatValue - colder) / (cold - colder)); else pixels[x + y * width] = ShallowColor; } // add a outline if (tiles[x,y].HeightType >= HeightType.Shore && tiles[x,y].HeightType != HeightType.River) { if (tiles[x,y].BiomeBitmask != 15) pixels[x + y * width] = Color.Lerp (pixels[x + y * width], Color.black, 0.35f); } } } texture.SetPixels(pixels); texture.wrapMode = TextureWrapMode.Clamp; texture.Apply(); return texture; }

Rendering these biome maps, gives us these beautiful wrapping world maps.

Up until this point, we have created worlds that wrap around the X and Y axis. These maps are great for games, as the data can easily be rendered as a game map.

If you wanted to project these wrappable textures onto a sphere, it would not look right. In order to make our world fit a sphere, we need to write a spherical texture generator. In this section, we will add this functionality to the worlds we have been generating.

The spherical generation is going to differ slightly from the wrappable generator, as it will require different noise patterns, and texture mapping. For this reason, we are going to branch off the generator class into two new sub classes, WrappableWorldGenerator and SphericalWorldGenerator, both will inherit from their base Generator class.

This will allow us to have shared core functionality, while providing custom extended features for each generator type.

The original Generator class will become abstract, as well as some of its functions:

protected abstract void Initialize(); protected abstract void GetData(); protected abstract Tile GetTop(Tile tile); protected abstract Tile GetBottom(Tile tile); protected abstract Tile GetLeft(Tile tile); protected abstract Tile GetRight(Tile tile);

The Initialize() and GetData() functions that we currently have, are tailored for the Wrappable worlds, therefore, we are going to have to implement new ones for the Spherical generator. We are also going to have to create new Tile fetch classes, as we are only going to be wrapping on the x-axis with these spherical projections.

We initialize the noise similarly, however, with one main difference. The heat map in this particular generator is not going to be wrapping on the y-axis. Because of this, we cannot create a proper gradient that we can multiply. Instead, we will manually do this, while generating the data.

protected override void Initialize() { HeightMap = new ImplicitFractal (FractalType.MULTI, BasisType.SIMPLEX, InterpolationType.QUINTIC, TerrainOctaves, TerrainFrequency, Seed); HeatMap = new ImplicitFractal(FractalType.MULTI, BasisType.SIMPLEX, InterpolationType.QUINTIC, HeatOctaves, HeatFrequency, Seed); MoistureMap = new ImplicitFractal (FractalType.MULTI, BasisType.SIMPLEX, InterpolationType.QUINTIC, MoistureOctaves, MoistureFrequency, Seed); }

The GetData function is going to change dramatically. We are now going to go back to sampling 3D noise. The noise will be sampled with the help of a latitude and longitude coordinate system.

I looked at how libnoise did their spherical mapping, and applied the same concept here. The main code is the following, which converts the latitude and longitude coordinates, into 3D spherical cartesian map coordinates.

void LatLonToXYZ(float lat, float lon, ref float x, ref float y, ref float z) { float r = Mathf.Cos (Mathf.Deg2Rad * lon); x = r * Mathf.Cos (Mathf.Deg2Rad * lat); y = Mathf.Sin (Mathf.Deg2Rad * lon); z = r * Mathf.Sin (Mathf.Deg2Rad * lat); }

The GetData function will then loop through all coordinates, using this conversion method to generate the map data. We sample the heat, height and moisture data using this method. The biome map is generated the same way as before, from the resulting moisture and heat maps.

protected override void GetData() { HeightData = new MapData (Width, Height); HeatData = new MapData (Width, Height); MoistureData = new MapData (Width, Height); // Define our map area in latitude/longitude float southLatBound = -180; float northLatBound = 180; float westLonBound = -90; float eastLonBound = 90; float lonExtent = eastLonBound - westLonBound; float latExtent = northLatBound - southLatBound; float xDelta = lonExtent / (float)Width; float yDelta = latExtent / (float)Height; float curLon = westLonBound; float curLat = southLatBound; // Loop through each tile using its lat/long coordinates for (var x = 0; x < Width; x++) { curLon = westLonBound; for (var y = 0; y < Height; y++) { float x1 = 0, y1 = 0, z1 = 0; // Convert this lat/lon to x/y/z LatLonToXYZ (curLat, curLon, ref x1, ref y1, ref z1); // Heat data float sphereValue = (float)HeatMap.Get (x1, y1, z1); if (sphereValue > HeatData.Max) HeatData.Max = sphereValue; if (sphereValue < HeatData.Min) HeatData.Min = sphereValue; HeatData.Data [x, y] = sphereValue; // Adjust heat based on latitude float coldness = Mathf.Abs (curLon) / 90f; float heat = 1 - Mathf.Abs (curLon) / 90f; HeatData.Data [x, y] += heat; HeatData.Data [x, y] -= coldness; // Height Data float heightValue = (float)HeightMap.Get (x1, y1, z1); if (heightValue > HeightData.Max) HeightData.Max = heightValue; if (heightValue < HeightData.Min) HeightData.Min = heightValue; HeightData.Data [x, y] = heightValue; // Moisture Data float moistureValue = (float)MoistureMap.Get (x1, y1, z1); if (moistureValue > MoistureData.Max) MoistureData.Max = moistureValue; if (moistureValue < MoistureData.Min) MoistureData.Min = moistureValue; MoistureData.Data [x, y] = moistureValue; curLon += xDelta; } curLat += yDelta; } }

Giving us our height map, heat map, moisture map, and biome map (respectively):

Notice that the maps curve near the corners. This is intentional, as it is how the spherical projection works. Let’s apply the biome texture onto a sphere and see how it looks:

Not a bad start. Now, you have have noticed that our height map is now black and white. This was done on purpose, as we are going to be using it as the height map for our sphere’s shader. We are also going to need a bump map to provide some extra effect. In order to generate the bump map, we will first render a black and white texture that represents what we want our distortion to be. This texture will then be processed into the actual bump map with the following code:

public static Texture2D CalculateBumpMap(Texture2D source, float strength) { Texture2D result; float xLeft, xRight; float yUp, yDown; float yDelta, xDelta; var pixels = new Color[source.width * source.height]; strength = Mathf.Clamp(strength, 0.0F, 10.0F); result = new Texture2D(source.width, source.height, TextureFormat.ARGB32, true); for (int by = 0; by < result.height; by++) { for (int bx = 0; bx < result.width; bx++) { xLeft = source.GetPixel(bx - 1, by).grayscale * strength; xRight = source.GetPixel(bx + 1, by).grayscale * strength; yUp = source.GetPixel(bx, by - 1).grayscale * strength; yDown = source.GetPixel(bx, by + 1).grayscale * strength; xDelta = ((xLeft - xRight) + 1) * 0.5f; yDelta = ((yUp - yDown) + 1) * 0.5f; pixels[bx + by * source.width] = new Color(xDelta, yDelta, 1.0f, yDelta); } } result.SetPixels(pixels); result.wrapMode = TextureWrapMode.Clamp; result.Apply(); return result; }

Feeding this function the texture on the left, gives us our bump map, represented on the right:

Now, if we apply this bump map along with the height map, onto our sphere via the standard shader, we get the following:

For some extra effect, we are now going to add some cloud layers. We can generate clouds with noise very easily, so why not. We will use a billow noise module to represent our clouds.

We are going to add two cloud layers to give it some depth. The code for the cloud noise generator is:

Cloud1Map = new ImplicitFractal(FractalType.BILLOW, BasisType.SIMPLEX, InterpolationType.QUINTIC, 5, 1.65f, Seed); Cloud2Map = new ImplicitFractal (FractalType.BILLOW, BasisType.SIMPLEX, InterpolationType.QUINTIC, 6, 1.75f, Seed);

We grab the data the same way. The cloud texture generator is just a simple color lerp from white to transparent white. We cut off the clouds at a set value, making everything else transparent. The code for the cloud texture generation is:

public static Texture2D GetCloudTexture(int width, int height, Tile[,] tiles, float cutoff) { var texture = new Texture2D(width, height); var pixels = new Color[width * height]; for (var x = 0; x < width; x++) { for (var y = 0; y < height; y++) { if (tiles[x,y].CloudValue > cutoff) pixels[x + y * width] = Color.Lerp(new Color(1f, 1f, 1f, 0), Color.white, tiles[x,y].CloudValue); else pixels[x + y * width] = new Color(0,0,0,0); } } texture.SetPixels(pixels); texture.wrapMode = TextureWrapMode.Clamp; texture.Apply(); return texture; }

With this, we can generate two different cloud textures. Again, these textures were sampled to be spherical, which will warp near the corners:

Next, two new sphere meshes were added, that are slightly larger than the original sphere. Applying the cloud textures, to the standard shader with a fade effect, gives us some decent looking cloud coverage:

Finally, here is a screenshot of all the textures that were generated, and used to create the final rendering of the planet:

That wraps it up for this tutorial series. You can get the full source code for this project on github.