Hello guys! This is the 21-th tutorial from my series, and it will teach you how to render terrains using heightmaps loaded from regular grayscale images. Our terrain will have multiple (three) layers of textures on it, which transition smoothly between selves. Not only that, we will also use a special "path" texture for describing path, pavement (or call it how you want ) on that terrain.
This is the longest article I have ever written, as this topic is pretty wide and there are lots of things asking for explanations . But if you go through it, you will receive like 1,000,000 Exp in OpenGL . So let's go.
Heightmap is in most common sense a 2D grid of values, each value meaning height in that point. Our ultimate goal is to read these heights from somewhere, create some rendering data from them, and then render the heightmap in 3D. I briefly explained heightmaps in Tutorial 5 - Indexed Drawing, so you can also have a look there.
The question now is - where and how to store these values? And you probably already know the answer - the easiest way to store heights is to create a heightmap image, where intensity (shade of grey) will represent height, with white intensity (255) being the maximum possible height and black (0) being the minimum possible height. This way, we can easily store 255 different heights, which is (for now) more than enough. The heightmap I'm using in this tutorial looks like this:
(The heightmap was created by me, in my own world editor, where you can raise hills with a circle, so that's why you can see so many circles in that heightmap)
But the point is, that these white circles are highest places in the map, and black spots are the lowest. If we stretch this small image over a 3D plane and then raise the vertices according to their corresponding heights, we will get our result - 3D heightmap.
As always, I will create a wrapper class, that will contain heightmap loading, rendering, releasing etc. This class is called CMultilayeredHeightmap. Here it is:
class CMultiLayeredHeightmap
{
public:
static bool LoadTerrainShaderProgram();
static void ReleaseTerrainShaderProgram();
bool LoadHeightMapFromImage(string sImagePath);
void ReleaseHeightmap();
void RenderHeightmap();
void SetRenderSize(float fQuadSize, float fHeight);
void SetRenderSize(float fRenderX, float fHeight, float fRenderZ);
int GetNumHeightmapRows();
int GetNumHeightmapCols();
static CShaderProgram* GetShaderProgram();
CMultiLayeredHeightmap();
private:
UINT uiVAO;
bool bLoaded;
bool bShaderProgramLoaded;
int iRows;
int iCols;
glm::vec3 vRenderScale;
CVertexBufferObject vboHeightmapData;
CVertexBufferObject vboHeightmapIndices;
static CShaderProgram spTerrain;
static CShader shTerrainShaders[NUMTERRAINSHADERS];
};
Let's analyze the function LoadHeightMapFromImage(). I will paste it here by parts, and we will analyze each part. So the function begins like this:
bool CMultiLayeredHeightmap::LoadHeightMapFromImage(string sImagePath)
{
if(bLoaded)
{
bLoaded = false;
ReleaseHeightmap();
}
FREE_IMAGE_FORMAT fif = FIF_UNKNOWN;
FIBITMAP* dib(0);
fif = FreeImage_GetFileType(sImagePath.c_str(), 0); // Check the file signature and deduce its format
if(fif == FIF_UNKNOWN) // If still unknown, try to guess the file format from the file extension
fif = FreeImage_GetFIFFromFilename(sImagePath.c_str());
if(fif == FIF_UNKNOWN) // If still unknown, return failure
return false;
if(FreeImage_FIFSupportsReading(fif)) // Check if the plugin has reading capabilities and load the file
dib = FreeImage_Load(fif, sImagePath.c_str());
if(!dib)
return false;
BYTE* bDataPointer = FreeImage_GetBits(dib); // Retrieve the image data
iRows = FreeImage_GetHeight(dib);
iCols = FreeImage_GetWidth(dib);
// We also require our image to be either 24-bit (classic RGB) or 8-bit (luminance)
if(bDataPointer == NULL || iRows == 0 || iCols == 0 || (FreeImage_GetBPP(dib) != 24 && FreeImage_GetBPP(dib) != 8))
return false;
// How much to increase data pointer to get to next pixel data
unsigned int ptr_inc = FreeImage_GetBPP(dib) == 24 ? 3 : 1;
// Length of one row in data
unsigned int row_step = ptr_inc*iCols;
// ...
}
The first part is almost identical to part where we're loading textures. We just use FreeImage library to load image data, deduce its properties - whether it's 24-bit RGB image or 8-bit intensity image (exactly what we need). So there are few variables, that are interesting to us - bDataPointer is the pointer in memory to image data, in a row-major order (the image is stored as rows from top to bottom). iRows, iCols is the number of rows and columns of our heightmap, i.e. the size of image, i.e. width and height of the image. So it means, that our heightmap will have exactly iRows*iCols vertices in it.
Also notice last two rows with two variables:
Knowing the meaning of all important variables and data of image loaded in memory, we can move on to building OpenGL objects of it.
bool CMultiLayeredHeightmap::LoadHeightMapFromImage(string sImagePath)
{
// ...
vboHeightmapData.CreateVBO();
// All vertex data are here (there are iRows*iCols vertices in this heightmap), we will get to normals later
vector< vector< glm::vec3> > vVertexData(iRows, vector(iCols));
vector< vector< glm::vec2> > vCoordsData(iRows, vector(iCols));
float fTextureU = float(iCols)*0.1f;
float fTextureV = float(iRows)*0.1f;
FOR(i, iRows)
{
FOR(j, iCols)
{
float fScaleC = float(j)/float(iCols-1);
float fScaleR = float(i)/float(iRows-1);
float fVertexHeight = float(*(bDataPointer+row_step*i+j*ptr_inc))/255.0f;
vVertexData[i][j] = glm::vec3(-0.5f+fScaleC, fVertexHeight, -0.5f+fScaleR);
vCoordsData[i][j] = glm::vec2(fTextureU*fScaleC, fTextureV*fScaleR);
}
}
// ...
}
This part ain't much difficult - what we do here is create a VBO in first line, adn then we want to precalculate vertex positions and its texture coordinates. What exactly we do is that we stretch heightmap points into range <-0.5, 0.5> on X and Z axis uniformly and <0.0, 1.0> on Y axis. The picture will surely help you to understand what I'm doing:
We wanna do it this way, because then we can easily scale whole heightmap to get desired render sizes. Evey single point in this grid is one height value, ranging from 0.0 to 1.0, which can be easily scaled to desired render height. And because it's in <-0.5, 0.5>, we will get the thing centered, for easier positioning of heightmap.
Another thing we are calculating here are texture coordinates. What we need to think through is how many times should we map a texture across heightmap. Well it depends on size of texture and things like that, but for this tutorial, I decided, that every 10 rows and every 10 columns we want to have one instance of texture in that dimension. That's why you can see two variables: fTextureU and fTextureV, which both represent how many times should we map the texture across respective dimension.
This was pretty easy part, only some linear calculations. The main concern in generating heightmap is calculating its normals.
Really, why?
OK, what's the problem with normals? First, let's think through what we have done so far. We have calculated only vertices with their texture coordinates. Let's imagine, we want to render heightmap now, without normals. How shall we do it? The answer is Indexed Rendering, as we did in Tutorial 5 - Indexed Drawing (why didn't I name it rendering instead? ). I will try to remind you this stuff here, but just briefly. Every row of quads in heightmap will be rendered using triangle strips. At the end of each row, we use primitive restart index to start a new bunch of triangle strips. This picture will help you to imagine it and understand - pay attention to the order of vertices, because this is the way I'm using:
As you can see, the rendering and wireframe model of heightmap is made up of triangles. And what we can do is to calculate the normal of each triangle. Yea, that's right. But does that help us? Yea, it does, but it isn't enough - every vertex of heightmap is surrounded by multiple triangles, so the normal of vertex should be calculated from normals of all surrounding triangles! In this tutorial, I just sum all normals of surrounding triangles, and then normalize final vector. Result looks more than fine.
In order to do so, I precalculate triangle normals first to have them all in one place. After this, I can proceed with calculating vertex normals. So we need to calculate normals for each quad in heightmap. There are (iRows-1)*(iCols-1) quads in heightmap. Every quad has two triangles. Therefore we need to calculate two normals per quad. Let's number them normal 0 and normal 1, as you can see here:
So what we gotta do is to precalculate these normals with consistent ordering of triangles. This code does exactly that:
bool CMultiLayeredHeightmap::LoadHeightMapFromImage(string sImagePath)
{
// ...
// Normals are here - the heightmap contains ( (iRows-1)*(iCols-1) quads, each one containing 2 triangles, therefore array of we have 3D array)
vector< vector > vNormals[2];
FOR(i, 2)vNormals[i] = vector< vector >(iRows-1, vector(iCols-1));
FOR(i, iRows-1)
{
FOR(j, iCols-1)
{
glm::vec3 vTriangle0[] =
{
vVertexData[i][j],
vVertexData[i+1][j],
vVertexData[i+1][j+1]
};
glm::vec3 vTriangle1[] =
{
vVertexData[i+1][j+1],
vVertexData[i][j+1],
vVertexData[i][j]
};
glm::vec3 vTriangleNorm0 = glm::cross(vTriangle0[0]-vTriangle0[1], vTriangle0[1]-vTriangle0[2]);
glm::vec3 vTriangleNorm1 = glm::cross(vTriangle1[0]-vTriangle1[1], vTriangle1[1]-vTriangle1[2]);
vNormals[0][i][j] = glm::normalize(vTriangleNorm0);
vNormals[1][i][j] = glm::normalize(vTriangleNorm1);
}
}
// ...
}
When we're done with it, we can calculate final normals from this data. Let's take one vertex into consideration. Which triangles are adjacent to it? Image will help you to see it:
Now, we need to sum normals of these triangles, and then normalize final vector. Code below is doing this final normal calculation:
bool CMultiLayeredHeightmap::LoadHeightMapFromImage(string sImagePath)
{
// ...
vector< vector > vFinalNormals = vector< vector >(iRows, vector(iCols));
FOR(i, iRows)
FOR(j, iCols)
{
// Now we wanna calculate final normal for [i][j] vertex. We will have a look at all triangles this vertex is part of, and then we will make average vector
// of all adjacent triangles' normals
glm::vec3 vFinalNormal = glm::vec3(0.0f, 0.0f, 0.0f);
// Look for upper-left triangles
if(j != 0 && i != 0)
FOR(k, 2)vFinalNormal += vNormals[k][i-1][j-1];
// Look for upper-right triangles
if(i != 0 && j != iCols-1)vFinalNormal += vNormals[0][i-1][j];
// Look for bottom-right triangles
if(i != iRows-1 && j != iCols-1)
FOR(k, 2)vFinalNormal += vNormals[k][i][j];
// Look for bottom-left triangles
if(i != iRows-1 && j != 0)
vFinalNormal += vNormals[1][i][j-1];
vFinalNormal = glm::normalize(vFinalNormal);
vFinalNormals[i][j] = vFinalNormal; // Store final normal of j-th vertex in i-th row
}
// ...
}
With all vertex data calculated, we can finally start adding them, vertex-by-vertex, to our first VBO:
bool CMultiLayeredHeightmap::LoadHeightMapFromImage(string sImagePath)
{
// ...
// First, create a VBO with only vertex data
vboHeightmapData.CreateVBO(iRows*iCols*(2*sizeof(glm::vec3)+sizeof(glm::vec2))); // Preallocate memory
FOR(i, iRows)
{
FOR(j, iCols)
{
vboHeightmapData.AddData(&vVertexData[i][j], sizeof(glm::vec3)); // Add vertex
vboHeightmapData.AddData(&vCoordsData[i][j], sizeof(glm::vec2)); // Add tex. coord
vboHeightmapData.AddData(&vFinalNormals[i][j], sizeof(glm::vec3)); // Add normal
}
}
// ...
}
Then we create our second VBO, that stores indices. Note, that primitive restart index is index witha value of iRows*iCols, because it's the first free number, as vertices are indexed from 0 to iRows*iCols -1 , inclusive. If you don't know how to that indexed rendering, I will redirect you again to Tutorial 5 - Indexed Drawing for explanation how this works:
bool CMultiLayeredHeightmap::LoadHeightMapFromImage(string sImagePath)
{
// ...
vboHeightmapIndices.CreateVBO();
int iPrimitiveRestartIndex = iRows*iCols;
FOR(i, iRows-1)
{
FOR(j, iCols)
FOR(k, 2)
{
int iRow = i+(1-k);
int iIndex = iRow*iCols+j;
vboHeightmapIndices.AddData(&iIndex, sizeof(int));
}
// Restart triangle strips
vboHeightmapIndices.AddData(&iPrimitiveRestartIndex, sizeof(int));
}
glGenVertexArrays(1, &uiVAO);
glBindVertexArray(uiVAO);
// Attach vertex data to this VAO
vboHeightmapData.BindVBO();
vboHeightmapData.UploadDataToGPU(GL_STATIC_DRAW);
// Vertex positions
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 2*sizeof(glm::vec3)+sizeof(glm::vec2), 0);
// Texture coordinates
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 2*sizeof(glm::vec3)+sizeof(glm::vec2), (void*)sizeof(glm::vec3));
// Normal vectors
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 2*sizeof(glm::vec3)+sizeof(glm::vec2), (void*)(sizeof(glm::vec3)+sizeof(glm::vec2)));
// And now attach index data to this VAO
// Here don't forget to bind another type of VBO - the element array buffer, or simplier indices to vertices
vboHeightmapIndices.BindVBO(GL_ELEMENT_ARRAY_BUFFER);
vboHeightmapIndices.UploadDataToGPU(GL_STATIC_DRAW);
bLoaded = true; // If get here, we succeeded with generating heightmap
return true;
}
And that's all from loading! Now everything is prepared, rendering is just a matter of few calls:
void CMultiLayeredHeightmap::RenderHeightmap()
{
spTerrain.UseProgram();
spTerrain.SetUniform("fRenderHeight", vRenderScale.y);
spTerrain.SetUniform("fMaxTextureU", float(iCols)*0.1f);
spTerrain.SetUniform("fMaxTextureV", float(iRows)*0.1f);
spTerrain.SetUniform("HeightmapScaleMatrix", glm::scale(glm::mat4(1.0), glm::vec3(vRenderScale)));
// Now we're ready to render - we are drawing set of triangle strips using one call, but we g otta enable primitive restart
glBindVertexArray(uiVAO);
glEnable(GL_PRIMITIVE_RESTART);
glPrimitiveRestartIndex(iRows*iCols);
int iNumIndices = (iRows-1)*iCols*2 + iRows-1;
glDrawElements(GL_TRIANGLE_STRIP, iNumIndices, GL_UNSIGNED_INT, 0);
}
Yea, calling render function was pretty short. But if we really want our terrain to have multiple layers of texture, we need to write a shader program for it. And that's the second part of this tutorial, because it's now a whole another thing. But it's really not difficult.
What we gotta do here is to render heightmap with texture according to its height.
Because our heightmap data are constructed in unit size, so that heightmap can be easily resizeable, first thing we will need to send to shader somehow is rendering size of heightmap. To do that, we simply send a scale matrix to our vertex shader. This is what our vertex shader looks like:
#version 330
uniform struct Matrices
{
mat4 projMatrix;
mat4 modelMatrix;
mat4 viewMatrix;
mat4 normalMatrix;
} matrices;
layout (location = 0) in vec3 inPosition;
layout (location = 1) in vec2 inCoord;
layout (location = 2) in vec3 inNormal;
smooth out vec2 vTexCoord;
smooth out vec3 vNormal;
smooth out vec3 vWorldPos;
smooth out vec4 vEyeSpacePos;
uniform mat4 HeightmapScaleMatrix;
void main()
{
vec4 inPositionScaled = HeightmapScaleMatrix*vec4(inPosition, 1.0);
mat4 mMVP = matrices.projMatrix*matrices.viewMatrix*matrices.modelMatrix;
gl_Position = mMVP*inPositionScaled;
vEyeSpacePos = matrices.viewMatrix*matrices.modelMatrix*vec4(inPosition, 1.0);
vTexCoord = inCoord;
vNormal = inNormal;
vec4 vWorldPosLocal = matrices.modelMatrix*inPositionScaled;
vWorldPos = vWorldPosLocal.xyz;
}
The uniform mat4 HeightmapScaleMatrix; is our scale matrix, which is set before rendering heightmap. Before we proceed with classic transformation, we will multiply incoming vertex position with our scale matrix and store it in inPositionScaled. Then, we work with everything as usual.
Fragment shader is the last important thing, that requires attention. The purpose of it is to have multiple textures in multiple samplers, and then map them to our terrain according to its height. For this tutorial, I decided to go for 3 different textures when mapping terrain. These are ranges:
To visualize this, here is a helpful picture:
For the purpose of this tutorial, the range values are hardcoded in fragment shader. Of course, you may extend this all, and create some uniform array of ranges, to be able to change things at runtime. So finally, here is the first part of fragment shader, that calculates proper color from these three textures. These textures can be accessed with gSampler[0], gSampler[1] and gSampler[2]:
#version 330
smooth in vec2 vTexCoord;
smooth in vec3 vNormal;
smooth in vec3 vWorldPos;
smooth in vec4 vEyeSpacePos;
uniform sampler2D gSampler[5];
uniform sampler2D shadowMap;
uniform vec4 vColor;
#include "dirLight.frag"
uniform DirectionalLight sunLight;
uniform float fRenderHeight;
uniform float fMaxTextureU;
uniform float fMaxTextureV;
out vec4 outputColor;
void main()
{
vec3 vNormalized = normalize(vNormal);
vec4 vTexColor = vec4(0.0);
float fScale = vWorldPos.y/fRenderHeight;
const float fRange1 = 0.15f;
const float fRange2 = 0.3f;
const float fRange3 = 0.65f;
const float fRange4 = 0.85f;
if(fScale >= 0.0 && fScale <= fRange1)vTexColor = texture2D(gSampler[0], vTexCoord);
else if(fScale <= fRange2)
{
fScale -= fRange1;
fScale /= (fRange2-fRange1);
float fScale2 = fScale;
fScale = 1.0-fScale;
vTexColor += texture2D(gSampler[0], vTexCoord)*fScale;
vTexColor += texture2D(gSampler[1], vTexCoord)*fScale2;
}
else if(fScale <= fRange3)vTexColor = texture2D(gSampler[1], vTexCoord);
else if(fScale <= fRange4)
{
fScale -= fRange3;
fScale /= (fRange4-fRange3);
float fScale2 = fScale;
fScale = 1.0-fScale;
vTexColor += texture2D(gSampler[1], vTexCoord)*fScale;
vTexColor += texture2D(gSampler[2], vTexCoord)*fScale2;
}
else vTexColor = texture2D(gSampler[2], vTexCoord);
// ...
}
Notice, that everytime we are in transitioning part, we calculate scale (fScale variable). Then we add fScale much of one texture, and 1-fScale much of second texture. Have a look at these lines, and think about them until you understand them for 100% - they are the core part of this fragment shader.
Everything is looking smooth and nice now. But what if I wanted to create a pavement going through arbitrary places? And yes, I do want to create one, so we will add additional functionality to the fragment shader, that will handle paths.
Our path needs two things - path texture, which will be mapped to places with path, and path map, which will tell fragment shader, where to put path. Our path map in this tutorial looks like this:
Our path texture, which is pure sand, is stored in gSampler[3], and path map is stored in gSampler[4]. What we need to do now is to map the path map texture over the whole heightmap. But the texture coordinates, that our fragment shader got from vertex shader are scaled according to number of columns and rows in the heightmap. So we need to rescale them first, and that's why I set uniform variables fMaxTextureU and fMaxTextureV, that tell us, what is the maximum texture coordinate value in each dimension. With this, we can easily find a path texture coordinate:
#version 330
smooth in vec2 vTexCoord;
smooth in vec3 vNormal;
smooth in vec3 vWorldPos;
smooth in vec4 vEyeSpacePos;
uniform sampler2D gSampler[5];
uniform sampler2D shadowMap;
uniform vec4 vColor;
#include "dirLight.frag"
uniform DirectionalLight sunLight;
uniform float fRenderHeight;
uniform float fMaxTextureU;
uniform float fMaxTextureV;
out vec4 outputColor;
void main()
{
// ...
vec2 vPathCoord = vec2(vTexCoord.x/fMaxTextureU, vTexCoord.y/fMaxTextureV);
vec4 vPathIntensity = texture2D(gSampler[4], vPathCoord); // Black color means there is a path
fScale = vPathIntensity.x;
vec4 vPathColor = texture2D(gSampler[3], vTexCoord);
vec4 vFinalTexColor = fScale*vTexColor+(1-fScale)*vPathColor;
vec4 vMixedColor = vFinalTexColor*vColor;
vec4 vDirLightColor = GetDirectionalLightColor(sunLight, vNormal);
outputColor = vMixedColor*(vDirLightColor);
}
Let's go through these lines:
The rest of the fragment shader is just applying directional light and outputting color. Hope this simple explanation helped you to understand it all. But I really encourage you to think through the whole fragment shader, analyze it, and really understand it, because this will raise your skills to another level and you will be able to apply this knowledge in many places .
Now before rendering our heightmap (calling RenderHeightmap() function, we only need to bind textures to different texture units and samplers. Here is the chunk of code that does this in RenderScene():
void RenderScene(LPVOID lpParam)
{
// ...
// Now we're going to render terrain
hmWorld.SetRenderSize(300.0f, 35.0f, 300.0f);
CShaderProgram* spTerrain = CMultiLayeredHeightmap::GetShaderProgram();
spTerrain->UseProgram();
spTerrain->SetUniform("matrices.projMatrix", oglControl->GetProjectionMatrix());
spTerrain->SetUniform("matrices.viewMatrix", cCamera.Look());
// We bind all 5 textures - 3 of them are textures for layers, 1 texture is a "path" texture, and last one is
// the places in heightmap where path should be and how intense should it be
FOR(i, 5)
{
char sSamplerName[256];
sprintf(sSamplerName, "gSampler[%d]", i);
tTextures[i].BindTexture(i);
spTerrain->SetUniform(sSamplerName, i);
}
// ... set some uniforms
spTerrain->SetModelAndNormalMatrix("matrices.modelMatrix", "matrices.normalMatrix", glm::mat4(1.0));
spTerrain->SetUniform("vColor", glm::vec4(1, 1, 1, 1));
dlSun.SetUniformData(spTerrain, "sunLight");
// ... and finally render heightmap
hmWorld.RenderHeightmap();
// ...
}
This is how our terrain looks like:
Pretty neat isn't it? Of course, don't forget to load heightmap first in InitScene function, and also release it in ReleaseScene function. I'm pretty sure that graphics card drivers can deal with not releasing things, but why make their lives harder?
The heightmap name is consider_this_question.bmp. I forgot to change it before I uploaded tutorial, so I let it there for now . There's story behind this name. When I was coding these things, the Dream Theater (my favorite band) album came out, and Consider this question is the first lyrical part of Illumination Theory song . So that's why, in case you are wondering >.
I'm not sure what next tutorial is gonna be about, but probably one of these things:
But I guess that the ordering I used here will be real ordering for next 3 tutorials. Farewell.
Download 27.17 MB (7075 downloads)