NEW TERRAIN SYSTEM(continued from previous post)
NEW TERRAIN SYSTEM: STATIC MESHES -> CUSTOM TERRAIN Fortunately, for converting static meshes to terrain, I was able to repurpose my
previous raycasting system which I developed a while ago.
For conversion I previously was using the
'Object2Terrain' C# script, which is a free editor script.
This is great for a free tool, however this has some problems:
-The script only supports one mesh, and the static mesh terrain is composed of many meshes.
-It autosizes your terrain to the bounds of the mesh, meaning terrains never have the same resolution.
-It operates off of raycasts, which aren't great for quality conversion
-If you need to set additional parameters for your terrain, you have to do this manually.
I'll go through how I fixed the major issues.
It Only Supports One Mesh: The default script determines the dimensions of converted terrain with the mesh's bounds.
By default, this does not include children meshes and therefore only supports a single mesh.
In order to support multiple meshes, you can
encapsulate the bounds of children meshes into a single bounds object.
Consistent Terrain Resolution:Unfortunately, Unity's terrains only support resolutions which are powers of two. (Ex. 256, 512, 1024).
The default script behavior would scale the terrain based off of the mesh bounds. However, resolution would always be a power of two.
This lead to an inconsistency in the quality of terrain, based on the difference between their resolution and size.
The solution to this problem was to fix the raycasting bounds based on powers of two, and set both the terrain's size and resolutions to the same value.
This can be determined with
logarithms and base shifting.Improving Raycast Quality: To convert from meshes to terrain, the script relies off of
raycasting.
This is an intuitive approach, however it creates precision errors which result in jaggedness of terrain, as you can see in the picture below.
A solution to improving raycast conversion quality is through
super sampling the raycasts.
By this, I simply mean make more raycasts than needed (such as a factor of 8x).
Afterwards, average adjacent raycasts together when creating the terrain's height map.
I was able to almost exactly use this script as it is, which uses raycasts to approximate a height map from a series of meshes.
However, the primary difference is instead of the data format being a
'Terrain Data' format, it's a multi-dimensional array of floats.
This floating point array (in the format of float[,]) is then baked into a heightmap texture.
---
NEW TERRAIN SYSTEM: GENERATING AND SERIALIZING HEIGHT MAPSBaking the heightmap floating point data into a texture is actually fairly simple.
After creating an appropriate texture, you can iterate through all of its pixels (in x, y coordinates) and use
'Texture2D.SetPixel' to set the data for that specific pixel.
What wasn't so simple, is what format of texture to use, and how to serialize it.
Initially, I was naïve, and thought that a .PNG would be a sufficient format to store the data in, which could be encoded using
this function.
However, I was very wrong. Your average .PNG file only has 8 bits of precision per channel, which means there are only 255 possible heights when storing a heightmap in one of the colors of the image.
You can clearly see these 255 heights in the below image of an earlier version of the terrain: notice the weird terraced slopes.
8 bits of height data with weird terraces, vs. 32 bits of height data which is nice and smoothNaturally, I decided to use a 32 bit single channel texture to store the heightmap data instead. In Unity terms, this is called a
'TextureFormat.RFloat'However, one issue of this format is that it can't be encoded in a common texture format to store on disk.
I tried looking into something like
EXR, as Unity supports encoding textures into this format, but I found it impractical for my use.
Fortunately, I discovered that you can serialize texture data in binary.
Using the
GetRawTextureData function, you can get a byte array from a texture, and the
LoadRawTextureData loads data into a texture from a byte array.
Referring to some other documentation on how to write a byte array to disk, I was able to successfully load and save this texture data in an intuitive binary format.
---
NEW TERRAIN SYSTEM: GENERATING NORMAL MAPS Now that I had a high precision height map texture which I could save and load, the next step was to generate a normal map texture for the terrain.
The terrain height map is used to offset the vertices of our height plane, procedurally. However, these vertices have no normals.
One can compute normals procedurally in a shader, (say by sampling adjacent heights), however, this is a bit expensive and will result in many texture samples per-vertex.
One could also compute the normals using
pixel derivatives in the fragment shader, but this will result in only flat normals and not smooth normals.
To create smooth normals, I instead opted to use a
Sobel filter to pre-generate a normal map texture, based on our height map data.
The Sobel filter function allows for creating smooth normals using weighted derivatives in each direction.
I adapted
an implementation from good old Stack Overflow into C# and Unity.
Essentially what this does, is given a height map coordinate, sample the eight adjacent coordinates to create a normal vector.
This is done for every pixel in the height map texture.
Pixel coordinates are as follows:
[n6][n7][n8]
[n3][n4][n5]
[n0][n1][n2]
float scale = NORMAL_STRENGTH; //Usually 64 is a good number for this
float nX = scale * -(n2 - n0 + 2 * (n5 - n3) + n8 - n6);
float nY = scale * -(n6 - n0 + 2 * (n7 - n1) + n8 - n2);
float nZ = Mathf.Sqrt((nX * nX + nY * nY));
//Clamp normal values and normalize
nX = Mathf.Max(0f, Mathf.Min(1f, nX));
nY = Mathf.Max(0f, Mathf.Min(1f, nY));
nZ = 1f - Mathf.Max(0f, Mathf.Min(1f, nZ));
Vector3 c = new Vector3(nY, nZ, nX);
c = Vector3.Normalize(c);
//NOTE: height is always the middle (g) component in Unity
Color normalColor = new Color(c.x, c.y, c.z, 1);
Something which definitely was tedious for me to figure out was the proper way to convert the results of the Sobel filter into an appropriate normal map color.
After loading the texture into a material and some debugging in the
frame debugger, I discovered the correct orientation and values for generating correct normal maps.
The orientation was a bit challenging to figure out, however something also important is that we want the normals to be clamped to 0-1 values, and for the normal vector to be normalized (of course lol).
Also important, is the normal strength value, which determines the weighted strength of the red and blue channels of the normal map. Determining this value is more of an art than a science, and depends on the dimensions of the height map itself.
The code for the Sobel filter and color generation is included above for some lost soul who wanders into my DevLog.
---
NEW TERRAIN SYSTEM: TERRAIN HEIGHTMAP DATA AND THE SHADERAfter generating the necessary maps needed for nice terrain, it was time to move on to working more on the actual terrain shader itself.
The concept is simple, given a height map, and a series of 2D plane meshes, offset the vertices of each plane based on the height map. However, there are a few important caveats which need to be considered.
BAKING MESH HEIGHTS FOR FRUSTUM CULLINGThe first step for all of this, is that we want to bake the plane's base meshes with a low resolution height map.
We need correct mesh bounds so Unity's frustum culling won't break the terrain. This is so when we are looking at a terrain tile, it won't be culled by the camera at weird angles.
This is easy to do, all that is necessary is reading the height map texture we generated, and then offsetting each vertex's y value by our height map value. Then we use
Mesh.RecalculateBounds on our resulting mesh.
BAKING MESH COLLISION DATAFor baking the mesh's collision data, I was able to use similar code to baking the basic height map data for the purpose of frustum culling.
However, we need significantly higher resolution for the player to walk on terrain tiles accurately.
I opted to sub-divide the base tile meshes by a factor of four, which gives reasonably high resolution for the terrain's collision.
Then, I use the exact same process for sampling the terrain's height map in world space, and offsetting the mesh vertices in C#.
The mesh collision data is currently serialized in the scene, but it's feasible to make it an asset instead for the future.
TESSELLATION OF THE TERRAIN'S PLANE MESHThe base mesh doesn't have enough resolution to render terrain, and needs to be tessellated.Now that our basic height map is baked, we need to tessellate it into a higher resolution to actually render the terrain effectively. To control the terrain's resolution in game, I opted for using GPU tessellation. I chose to use the same code for tessellation as I do for my water shader.
You can find more about tessellation, and how to write it from scratch,
in this tutorial over here!SAMPLING IN WORLD SPACE: TILESTo actually sample the terrain, I decided that this should be done in world space rather than local space. This is because I wanted the terrain to be composed of mesh 'tiles' which form a larger shape.
The benefit of this is I wouldn't have to have a texture for every individual tile of terrain. For example, if there was an 8x8 grid of tiles, one would think there would have to be 64 different terrain textures which are split up from the main texture.
Fortunately, because the texture is sampled in world space, I only have to use one texture for all of the tiles.
Additionally, I can make the tiles whatever weird 2D shape that I want, and they will still work perfectly.
This would be beneficial in making sure only necessary areas of terrain are rendered, as to not waste computation.
In the future, I may write an occlusion culling algorithm for terrain which will automatically disable tiles which cannot be seen by the player.
To correctly sample in world space, I simply pre-compute the world space offset of the terrain from origin (0,0,0) and feed that into the shader.
When sampling the texture based off of the world space position of the vertex, I apply this offset.
Note that because we are sampling the texture in the vertex shader, we need to use the SAMPLE_TEXTURE2D_LOD function instead of SAMPLE_TEXTURE2D.
//Sample the texture based on the xz coordinates of the vertex in world space.
float2 samplePosition = position.xz;
//Subtract offset, and divide by resolution.
samplePosition.xy = samplePosition.xy - float2(offset.x,offset.z);
samplePosition /= _TextureResolution;
float4 heightMap = SAMPLE_TEXTURE2D_LOD(_Heightmap, sampler_Heightmap, samplePosition, float2(0,0));
//Sample the texture, and multiply by our height resolution
float height = heightMap.r * _HeightResolution;
This code results in correctly sampling the height map in world space, in our vertex function. The plane's vertices are then offset by the height value.
USING THE NORMAL MAP TEXTUREWhat the terrain's normals look like, shown with a debug shader.As discussed previously, because we are dynamically offsetting the height map's vertices, we would need to re-compute the mesh's normals to get correct rendering.
Otherwise, the mesh's normals would be incorrect, since without any offset, a flat plane's object space normal would always be (0,1,0).
Fortunately, we don't have to do this, because we pre-generated our normal map values ahead of time.
In the vertex function, we simply assign the object space and world space normal values, based off of our normal map.
---
NEW TERRAIN SYSTEM: EROSION The final step to a good looking terrain height map, is an erosion simulation. I was aiming to simulate three types of erosion which I think could give cool results.
HYDRAULIC EROSION Hydraulic Erosion simulates water droplets on terrain, and sediment being carried from terrain slopes to terrain valleys.
For a simulation of Hydraulic Erosion, I ended up using an algorithm which loosely referenced Sebastian Lague's research on the subject.
I had to write code which would work with my own terrain system, however the concept were pretty solid and very helpful in the overall implementation.
THERMAL EROSIONThermal Erosion is the process of how heat changes affect terrain. Think about glaciers, or incredibly hot and dry areas of terrain.
Fortunately, I was able to adapt the thermal erosion code from
Terrain Toolkit, which I have been using for years, into my own terrain system.
TIDAL EROSIONTidal Erosion is the simulation of waves eroding sea-side terrain, to form interesting cliffs and beaches. Fortunately, I was again able to adapt the tidal erosion code from
Terrain Toolkit, although it had to be changed slightly due to some data formatting issues.
---
FINAL RESULTS IN GAMEWith all of the other game's assets included, such as the architecture, sky, trees, water, and procedurally generated snow, the world truly seems alive.
I'm very satisfied with the results of the terrain system. For about a week worth of work, it adds an immense amount to the game.
---