Friday 13 December 2013

Reinventing some of Unity’s Wheels: Terrain

Why?

First of all, why? Why create this when there is a perfectly good terrain package that comes with Unity? Well, I do like to have a mess about and find out what I can do, no other reason than that really, why? Because I can :) I dare say that the Unity Terrain asset is a much better way of creating your terrain, having not really played with it I don’t know, but I would like to implement my version of Geo Clip mapping see here:
And to do that I am going to need to implement height maps and generate the geometry as I need to. Also, later on I may want to procedurally generate my terrain, and I am guessing Ill need something like this.

How?

I started off trying to find out if I could generate my own geometry in Unity, and it turns out you can, this was great news on my quest as it meant I can pass my script a height map and using that to generate the mesh needed to render the terrain.
I started off creating a script called TerrainGenerator in my Unity project, in here I added a number of public parameters so they can be set in the editor and I can pass in the height map and the min and max height of my terrain, the txMod is just a variable I can use to modify the texcoords in the vertex, it’s a throw back to the version I did in XNA, and as you can change an assets tiling in the editor, probably not needed now.
public class TerrainGenerator : MonoBehaviour
{
    public Texture2D HeightMap;

    public float min = 0;
    public float max = 30;

    public float txMod = 4.25f;
I then add an Awake method, this gets called when the script is instanced, in here is where I read the height map and turn it into a mesh, this mesh, if a mesh collider is provided can then be used for that mesh collider, naturally you can’t set this in the editor as the mesh has not been created yet :)
Naturally you will need a mesh filter to store the mesh in, if you don’t Unity will throw an error when you go to set the mesh in it. I then check to see if a height map has been given and if not generate a plane and render that instead.
Here is the script in it’s entirety:-
public class TerrainGenerator : MonoBehaviour
{
    public Texture2D HeightMap;

    public float min = 0;
    public float max = 30;

    public float txMod = 4.25f;

    int myWidth;
    int myHeight;
    float[,] height;



    void Awake()
    {
        MeshFilter meshFilter = GetComponent<MeshFilter>();

        meshFilter.mesh = new Mesh();

        if (HeightMap != null)
        {
            myWidth = HeightMap.width;
            myHeight = HeightMap.height;

            transform.position = new Vector3(transform.position.x - (myWidth / 2), transform.position.y, transform.position.z - (myHeight / 2));

            Color[] heightMapCol = new Color[myWidth * myHeight];

            Vector3[] verts = new Vector3[myWidth * myHeight];
            Vector3[] normals = new Vector3[myWidth * myHeight];
            Vector2[] uv = new Vector2[myWidth * myHeight];
            Vector4[] tan = new Vector4[myWidth * myHeight];
            Color[] col = new Color[myWidth * myHeight];

            height = new float[myWidth, myHeight];

            heightMapCol = HeightMap.GetPixels();

        
            for (int x = 0; x < myWidth; x++)
                for (int y = 0; y < myHeight; y++)
                    height[x, y] = Mathf.Lerp(min, max, heightMapCol[x + y * myWidth].r);


            // Verts
            for (int x = 0; x < myWidth; x++)
                for (int y = 0; y < myHeight; y++)
                {
                    verts[x + y * myWidth] = new Vector3(y, height[x, y], x);
                    normals[x + y * myWidth] = Vector3.up;
                    uv[x + y * myWidth] = new Vector2((float)x / (myWidth / txMod), (float)y / (myHeight / txMod));

                    // blend
                    col[x + y * myWidth].r = Mathf.Clamp(1.0f - Mathf.Abs(height[x, y] - 0) / 8, 0, 1);
                    col[x + y * myWidth].g = Mathf.Clamp(1.0f - Mathf.Abs(height[x, y] - 12) / 6, 0, 1);
                    col[x + y * myWidth].b = Mathf.Clamp(1.0f - Mathf.Abs(height[x, y] - 20) / 6, 0, 1);
                    col[x + y * myWidth].a = Mathf.Clamp(1.0f - Mathf.Abs(height[x, y] - 30) / 6, 0, 1);

                    float totalWeight = col[x + y * myWidth].r;
                    totalWeight += col[x + y * myWidth].g;
                    totalWeight += col[x + y * myWidth].b;
                    totalWeight += col[x + y * myWidth].a;

                    col[x + y * myWidth].r /= totalWeight;
                    col[x + y * myWidth].g /= totalWeight;
                    col[x + y * myWidth].b /= totalWeight;
                    col[x + y * myWidth].a /= totalWeight;
                }

        
            // Calc normals
            for (int x = 0; x < myWidth; x++)
                for (int y = 0; y < myHeight; y++)
                {
                    // Tangent Data.
                    if (x != 0 && x < myWidth - 1)
                        tan[x + y * myWidth] = verts[x + 1 + y * myWidth] - verts[x - 1 + y * myWidth];
                    else
                        if (x == 0)
                            tan[x + y * myWidth] = verts[x + 1 + y * myWidth] - verts[x + y * myWidth];
                        else
                            tan[x + y * myWidth] = verts[x + y * myWidth] - verts[x - 1 + y * myWidth];

                    tan[x + y * myWidth].Normalize();

                    // Normals
                    Vector3 normX = Vector3.one;
                    Vector3 normZ = Vector3.one;

                    if (x != 0 && x < myWidth - 1)
                        normX = new Vector3((verts[x - 1 + y * myWidth].y - verts[x + 1 + y * myWidth].y) / 2, 1, 0);
                    else
                        if (x == 0)
                            normX = new Vector3((verts[x + y * myWidth].y - verts[x + 1 + y * myWidth].y) / 2, 1, 0);
                        else
                            normX = new Vector3((verts[x - 1 + y * myWidth].y - verts[x + y * myWidth].y) / 2, 1, 0);

                    if (y != 0 && y < myHeight - 1)
                        normZ = new Vector3(0, 1, (verts[x + (y - 1) * myWidth].y - verts[x + (y + 1) * myWidth].y) / 2);
                    else
                        if (y == 0)
                            normZ = new Vector3(0, 1, (verts[x + y * myWidth].y - verts[x + (y + 1) * myWidth].y) / 2);
                        else
                            normZ = new Vector3(0, 1, (verts[x + (y - 1) * myWidth].y - verts[x + (y) * myWidth].y) / 2);

                    normals[x + y * myWidth] = normX + normZ;
                    normals[x + y * myWidth].Normalize();
                }

        
            meshFilter.mesh.vertices = verts;
            meshFilter.mesh.tangents = tan;
            meshFilter.mesh.normals = normals;
            meshFilter.mesh.uv = uv;
            meshFilter.mesh.colors = col;

            // Index
            int[] terrainIndices = new int[(myWidth - 1) * (myHeight - 1) * 6];
            for (int x = 0; x < myWidth - 1; x++)
            {
                for (int y = 0; y < myHeight - 1; y++)
                {
                    terrainIndices[(x + y * (myWidth - 1)) * 6 + 5] = ((x + 1) + (y + 1) * myWidth);
                    terrainIndices[(x + y * (myWidth - 1)) * 6 + 4] = ((x + 1) + y * myWidth);
                    terrainIndices[(x + y * (myWidth - 1)) * 6 + 3] = (x + y * myWidth);

                    terrainIndices[(x + y * (myWidth - 1)) * 6 + 2] = ((x + 1) + (y + 1) * myWidth);
                    terrainIndices[(x + y * (myWidth - 1)) * 6 + 1] = (x + y * myWidth);
                    terrainIndices[(x + y * (myWidth - 1)) * 6] = (x + (y + 1) * myWidth);
                }
            }

            meshFilter.mesh.triangles = terrainIndices;
        }
        else
        {

            // Verts
            Vector3[] verts = new Vector3[4];

            verts[0] = new Vector3(-1, 0, -1);
            verts[1] = new Vector3(1, 0, -1);
            verts[2] = new Vector3(-1, 0, 1);
            verts[3] = new Vector3(1, 0, 1);

            meshFilter.mesh.vertices = verts;

            // Index
            int[] index = new int[6];

            index[0] = 0;
            index[1] = 2;
            index[2] = 1;

            index[3] = 2;
            index[4] = 3;
            index[5] = 1;

            meshFilter.mesh.triangles = index;

            // Normals
            Vector3[] normals = new Vector3[4];

            normals[0] = Vector3.up;
            normals[1] = Vector3.up;
            normals[2] = Vector3.up;
            normals[3] = Vector3.up;

            meshFilter.mesh.normals = normals;

            // UV
            Vector2[] uv = new Vector2[4];

            uv[0] = new Vector2(0, 0);
            uv[1] = new Vector2(1, 0);
            uv[2] = new Vector2(0, 1);
            uv[3] = new Vector2(1, 1);

            uv[0] = Vector3.zero;
            uv[1] = Vector3.zero;
            uv[2] = Vector3.zero;
            uv[3] = Vector3.zero;

            meshFilter.mesh.uv = uv;
        }

        MeshCollider mc = GetComponent<MeshCollider>();

        if (mc != null)
            mc.sharedMesh = meshFilter.mesh;
    }

}
Now, a word of warning, in order for this to work you MUST set your heightmap textures in Unity to be readable, by default the texture importer locks it, simply select your height map, choose the “Advanced” texture type and set the “Read\Write” enabled to true. If you don’t do this you will get an error on the line calling GetPixels()
I had a bit of a shock when I first ported this code from XNA, nothing rendered, but I had no errors, then I found out that Unity is left handed, so I just had to reverse the winding order of the index and it worked :) something to be mindful of if you are porting code from XNA.
So, that will generate your mesh AND if you give it a mesh collider, will add it to the Unity Physics system too, it was quite cool to then just add some sphere’s and cubes, give them a rigid body and watch them roll and tumble down the hills :D
So, you can now render the terrain and with a diffuse shader will look something like this:
885017_10151806984342218_1902863551_o[1]
If you look at the code above, you can see I am setting the vertex colour, I am actually not using this as a colour, but a blend weight used to blend the textures as the height of the terrain alters, my custom shader will now use that value to determine what textures should be used where. Also note that Unity (well there may be a way around this, but I am new to Unity so forgive me) will not render large height maps with this technique, if I go for a big height map texture I get this error:
Mesh.vertices is too large. A mesh may not have more than 65000 vertices.
UnityEngine.Mesh:set_vertices(Vector3[])  

Custom Terrain Shader

Again, this was a pretty simple port from my XNA sample to Unity, main issue I had was working out how to extend the vertex structure, but finding you can’t and having to use the Color element of the vertx to store my blend values in. I guess I could calculate them in the shader, but that might be another post ;) Again, the online Unity docs were a massive help with this.
My shader properties are pretty simple, 4 textures along with their bump maps:
Properties
{
  _MainTex ("Dirt", 2D) = "red" {}
  _BumpMap ("Dirt Bumpmap", 2D) = "bump" {}

  _MainTex2 ("Grass", 2D) = "green" {}
  _BumpMap2 ("Grass Bumpmap", 2D) = "bump" {}

  _MainTex3 ("Stone", 2D) = "grey" {}
  _BumpMap3 ("Stone Bumpmap", 2D) = "bump" {}

  _MainTex4 ("Snow", 2D) = "white" {}
  _BumpMap4 ("Snow Bumpmap", 2D) = "bump" {}
}
I did run out of arithmetic instruction so had to set the SM to 3.0 as well as add a vertex fragment to the shader to push on my blend data like this
#pragma surface surf Lambert vertex:vert
#pragma target 3.0
Then we just calculate the texture and bump map values based on the blend settings in the surface shader :)
void vert (inout appdata_full v, out Input o)
{
    UNITY_INITIALIZE_OUTPUT(Input,o);
    o.customColor = abs(v.color);
}

void surf (Input IN, inout SurfaceOutput o)
{

  float3 col = 0;
  col = tex2D(_MainTex,IN.uv_MainTex) * IN.customColor.x;
  col += tex2D(_MainTex2,IN.uv_MainTex) * IN.customColor.y;
  col += tex2D(_MainTex3,IN.uv_MainTex) * IN.customColor.z;
  col += tex2D(_MainTex4,IN.uv_MainTex) * IN.customColor.w;

  o.Albedo = pow(col * .5,1.25);

  float3 n = 0;
  n = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap) * IN.customColor.x);
  n += UnpackNormal (tex2D (_BumpMap2, IN.uv_BumpMap) * IN.customColor.y);
  n += UnpackNormal (tex2D (_BumpMap3, IN.uv_BumpMap) * IN.customColor.z);
  n += UnpackNormal (tex2D (_BumpMap4, IN.uv_BumpMap) * IN.customColor.w);
  o.Normal = n;
}
And shazam! The blighter only worked :D
1425267_10151810425112218_1951188246_o[1]
To implement it, I created an empty game object, gave it the terrain script, a mesh filter, a mesh renderer and a mesh collider, I then changed the shader to be my custom\terrainshader set the params on script and shader as I wanted.
There are a few other bits and bobs in there to iron out and polish, but think I am now ready to move on to that geo clip map implementation :) I hope it’s going to be as easy as I think it will be….
Here is my shader in full
Shader "Custom/TerrainShader"
{
    Properties
    {
      _MainTex ("Dirt", 2D) = "red" {}
      _BumpMap ("Dirt Bumpmap", 2D) = "bump" {}

      _MainTex2 ("Grass", 2D) = "green" {}
      _BumpMap2 ("Grass Bumpmap", 2D) = "bump" {}

      _MainTex3 ("Stone", 2D) = "grey" {}
      _BumpMap3 ("Stone Bumpmap", 2D) = "bump" {}

      _MainTex4 ("Snow", 2D) = "white" {}
      _BumpMap4 ("Snow Bumpmap", 2D) = "bump" {}
    }
    SubShader
    {
      Tags { "RenderType" = "Opaque" }
      CGPROGRAM
  
      #pragma surface surf Lambert vertex:vert
      #pragma target 3.0

      struct Input
      {
        float2 uv_MainTex;
        float2 uv_BumpMap;

        float4 customColor;
      };
  
      sampler2D _MainTex;
      sampler2D _BumpMap;

      sampler2D _MainTex2;
      sampler2D _BumpMap2;

      sampler2D _MainTex3;
      sampler2D _BumpMap3;

      sampler2D _MainTex4;
      sampler2D _BumpMap4;

      void vert (inout appdata_full v, out Input o)
      {
          UNITY_INITIALIZE_OUTPUT(Input,o);
          o.customColor = abs(v.color);
      }
  
      void surf (Input IN, inout SurfaceOutput o)
      {

        float3 col = 0;
        col = tex2D(_MainTex,IN.uv_MainTex) * IN.customColor.x;
        col += tex2D(_MainTex2,IN.uv_MainTex) * IN.customColor.y;
        col += tex2D(_MainTex3,IN.uv_MainTex) * IN.customColor.z;
        col += tex2D(_MainTex4,IN.uv_MainTex) * IN.customColor.w;

        o.Albedo = pow(col * .5,1.25);

        float3 n = 0;
        n = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap) * IN.customColor.x);
        n += UnpackNormal (tex2D (_BumpMap2, IN.uv_BumpMap) * IN.customColor.y);
        n += UnpackNormal (tex2D (_BumpMap3, IN.uv_BumpMap) * IN.customColor.z);
        n += UnpackNormal (tex2D (_BumpMap4, IN.uv_BumpMap) * IN.customColor.w);
        o.Normal = n;
      }
      ENDCG
    }
    Fallback "Diffuse"
  }
As ever, comments are more than welcome.

[Edit]
The discovery of the class attribute [ExecuteInEditMode] means that my height map terrain can now be viewed in the editor too :D Awesome !!



[/Edit]


No comments:

Post a Comment