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.