Generating Terrain Textures from Heightmaps

Prerequisites

Heightmaps Show

Noise Functions Show

Generating Terrain Textures from Heightmaps

Introduction

  • There are several algorithms available for generating heightmaps:
    • Midpoint displacement
    • Diamond Square
    • Perlin Noise
  • The below heightmap is generated using the diamond-square algorithm.
    • It has also been dilated to flatten it out slightly.
  • We can convert this heightmap to a set of vertices and render it as a terrain:
  • However, rendering it as a solid green color isn't very interesting, in this article we will generate a more detailed texture for it.

Finding the Derivative

  • Ideally we want to color the flat areas green to represent grass, while the slopes should be colored brown, to represent dirt/rock.
  • The difference between a flat area and a sloped area is the gradient, to find the gradient for an image we need to calculate its derrivative.
  • To do this we can use the Sobel operator:
    private static Mat getImageDerivative(Mat heightmap)
    {
        Mat horiz = new Mat();
        CvInvoke.Sobel(heightmap, horiz, DepthType.Cv16S, 1, 0);
    
        Mat horizABS = new Mat();
        CvInvoke.ConvertScaleAbs(horiz, horizABS, 1, 0);
    
        Mat vert = new Mat();
        CvInvoke.Sobel(heightmap, vert, DepthType.Cv16S, 0, 1);
    
        Mat vertABS = new Mat();
        CvInvoke.ConvertScaleAbs(vert, vertABS, 1, 0);
    
        Mat derivative = new Mat();
        CvInvoke.AddWeighted(horizABS, 0.5, vertABS, 0.5, 0, derivative);
        
        //the derivative will have values in range minVal to maxVal
        //we need convert this to the range 0-255
    
        double minVal = 0;
        double maxVal = 0;
        Point minLoc = new Point();
        Point maxLoc = new Point();
        CvInvoke.MinMaxLoc(derivative, ref minVal, ref maxVal, ref minLoc, ref maxLoc);
    
        CvInvoke.ConvertScaleAbs(derivative, derivative, 255 / Math.Max(-minVal, maxVal), 0);
                
        return derivative;
    }
  • This produces an image of the gradient:
  • In the above image, the dark pixels represent flat areas while the light pixels represent slopes.
  • To get a sense of how this works, let's render it as a texture on our terrain:
  • If you look closely, you can see the slopes are bright while the flat areas are dark.

Processing the Derivative

  • Having an image that gets brighter as the gradient increases is fine for testing, but it's not really how it works in the real world.
  • Instead, let's split the image into black and white, black for flat areas and white for slopes:
    private static Mat convertToBinary(Mat derivative)
    {
        Mat blackAndWhite = new Mat(derivative.Width, derivative.Height, DepthType.Cv8U, 1);
        MCvScalar lower = new MCvScalar(0);
        MCvScalar upper = new MCvScalar(64);
    
        //pixels in the range 0-64 will be set to black
        //pixels in the range 65-255 will be set to white
        CvInvoke.InRange(derivative, new ScalarArray(lower), new ScalarArray(upper), blackAndWhite);
        return blackAndWhite;
    }
  • Instead of white and black, let's use more natural colors:
    private static Mat colorizeImage(Mat blackAndWhite)
    {
        MCvScalar brownBGR = new MCvScalar(54, 75, 128);
        MCvScalar greenBGR = new MCvScalar(68, 128, 68);
    
        Mat terrain = new Mat(blackAndWhite.Width, blackAndWhite.Height, DepthType.Cv8U, 3);
        terrain.SetTo(brownBGR, blackAndWhite);
    
        CvInvoke.BitwiseNot(blackAndWhite, blackAndWhite);
        terrain.SetTo(greenBGR, blackAndWhite);
    
        return terrain;
    }

Adding Shadows

  • In real landscapes, lower regions are darker while higher regions are brighter.
  • Luckily our heightmap already contains this information! We can add this to our terrain texture:
    private static Mat addHeightmapToTerrain(Mat terrain, Mat heightmap)
    {
        Mat hsv = new Mat();
        CvInvoke.CvtColor(terrain, hsv, ColorConversion.Bgr2Hsv);
    
        Mat[] channels = hsv.Split();
    
        //we merge the heightmap with the vibrance channel of the image
        CvInvoke.AddWeighted(channels[2], 0.5, heightmap, 0.5, 0, channels[2]);
    
        CvInvoke.Merge(new VectorOfMat(channels), hsv);
        CvInvoke.CvtColor(hsv, terrain, ColorConversion.Hsv2Bgr);
    
        return terrain;
    }

Adding Texture

  • Our terrain is still only using two solid colors, let's add some texture to them:
    private static Mat addNoiseToTerrain(Mat terrain)
    {
        Mat hsv = new Mat();
        CvInvoke.CvtColor(terrain, hsv, ColorConversion.Bgr2Hsv);
    
        Mat[] channels = hsv.Split();
    
        Random random = new Random();
        byte[] buffer = new byte[hsv.Width * hsv.Height];
        random.NextBytes(buffer);
    
        GCHandle pinnedArray = GCHandle.Alloc(buffer, GCHandleType.Pinned);
        IntPtr pointer = pinnedArray.AddrOfPinnedObject();
    
        Mat randomMat = new Mat(hsv.Width, hsv.Height, DepthType.Cv8U, 1, pointer, hsv.Width);
        CvInvoke.AddWeighted(channels[2], 0.8, randomMat, 0.2, 0, channels[2]);
        
        pinnedArray.Free();
    
        CvInvoke.Merge(new VectorOfMat(channels), hsv);
        CvInvoke.CvtColor(hsv, terrain, ColorConversion.Hsv2Bgr);
    
        return terrain;
    }

Adding Outlines

  • The transition from green to brown should show a difference in gradient, but we can make it even clearer by adding a thin black line between the two.
  • We can achieve this by finding the second derivative of the image:
First and second derivative
Code
Mat secondDerivative = getImageDerivative(blackAndWhite);
MCvScalar black = new MCvScalar(0, 0, 0);
terrain.SetTo(black, secondDerivative);
Output

Final Result

Implementation Details

  • The image processing code is written in c# + EmguCV.
  • The terrain is scaled up to be 10x larger than the heightmap.
  • The 3D images were rendered in OpenTK.