Multi-textured Terrain in OpenGL


Terrain Sample

Terrain Sample

In this article I will demonstrate one possible way to generate multi-textured terrain using only the OpenGL rendering pipeline. This demo uses the GL_ARB_multitexture and GL_ARB_texture_env_combine OpenGL extensions to do the multi-textured blending based on the height of the vertex in the terrain.  I will also use the GL_ARB_vertex_buffer_object extension to store the terrain’s vertex information in the GPU memory for optimized rendering.

I will not show how to setup an application that uses OpenGL.  If you would like to review how to setup an OpenGL application you can refer to my previous article titled “Introduction to OpenGL for Game Programmers“.

Introduction

Terrain rendering is a common task for game programmers. Almost every 3D based game uses some form of terrain rendering in their game. It is the task of the programmer to try to develop a way to simulate a natural looking terrain in an efficient manner. At the same time the goal of the programmer is to give the game designers and level designers the necessary flexibility to manipulate the terrain exactly the way they want it.

In this simple demo application, I do not try to demonstrate a class that provides a lot of flexibility for the game designer as my goal for this demo is not flexibility but simplicity. That being said, let’s see how we can create a multi-textured terrain in OpenGL!

Dependencies

A few dependencies are used by this project to ease the coding process and to make the material more readable.

  • OpenGL Utility Toolkit (GLUT): Is used for platform-independent window management and window event handling that simplifies the implementation of an OpenGL windowed application.
  • OpenGL Extension Wrangler (GLEW): The OpenGL extension wrangler is the API I chose to check for the presence of the required extensions and to use the extensions in the application program.
  • OpenGL Mathmatics Library (GLM): Used for vector classes, quaternion classes, matrix classes and math operations that are suited for OpenGL applications.
  • Simple OpenGL Image Library (SOIL): A library that provides functions to load textures from disc as easy as possible.  This library is ideal for loading images to be used by OpenGL.

The Terrain Class

The only class that is required for this demo is the Terrain class. The Terrain class is responsible for loading the heightmap, loading the terrain textures, building the vertex arrays and rendering the terrain. The terrain does not need to be updated each frame so it doesn’t have an update method.

The Terrain Header File

The terrain class will have some basic functionality.  It will be able to load the heightmap from a RAW file that has been exported from Terragen Classic, load texture files that will be applied to the terrain, query the height of the terrain at a given position in world space, and render the terrain.  Since the terrain will generally remain static, there is no need to update the terrain.

#pragma once;

class Terrain
{
public:

    Terrain( float heightScale = 500.0f, float blockScale = 2.0f );
    virtual ~Terrain();

    void Terminate();
    bool LoadHeightmap( const std::string& filename, unsigned char bitsPerPixel, unsigned int width, unsigned int height );
    bool LoadTexture( const std::string& filename, unsigned int textureStage = 0 );

    // Get the height of the terrain at a position in world space
    float GetHeightAt( const glm::vec3& position );

    void Render();
    // In debug builds, the terrain normals will be rendered.
    void DebugRender();

protected:
    void GenerateIndexBuffer();
    void GenerateNormals();
    // Generates the vertex buffer objects from the
    // position, normal, texture, and color buffers
    void GenerateVertexBuffers();

    void RenderNormals();

private:
    typedef std::vector<glm::vec3>  PositionBuffer;
    typedef std::vector<glm::vec4>  ColorBuffer;
    typedef std::vector<glm::vec3>  NormalBuffer;
    typedef std::vector<glm::vec2>  TexCoordBuffer;
    typedef std::vector<GLuint>     IndexBuffer;

    PositionBuffer  m_PositionBuffer;
    ColorBuffer     m_ColorBuffer;
    NormalBuffer    m_NormalBuffer;
    TexCoordBuffer  m_Tex0Buffer;
    IndexBuffer     m_IndexBuffer;

    // ID's for the VBO's
    GLuint  m_GLVertexBuffer;
    GLuint  m_GLNormalBuffer;
    GLuint  m_GLColorBuffer;
    GLuint  m_GLTex0Buffer;
    GLuint  m_GLTex1Buffer;
    GLuint  m_GLTex2Buffer;
    GLuint  m_GLIndexBuffer;

    static const unsigned int m_uiNumTextures = 3;
    GLuint  m_GLTextures[m_uiNumTextures];

    glm::mat4x4 m_LocalToWorldMatrix;

    // The dimensions of the heightmap texture
    glm::uivec2 m_HeightmapDimensions;

    // The height-map value will be multiplied by this value
    // before it is assigned to the vertex's Y-coordinate
    float   m_fHeightScale;
    // The vertex's X and Z coordinates will be multiplied by this
    // for each step when building the terrain
    float   m_fBlockScale;
};

As you can see the Terrain class is very basic. It consists of the following member functions:

  • Terrain( float heightScale, float blockScale ): The constructor accepts two parameters:
    • float heightScale: determines the maximum height of the terrain in world units.
    • float blockScale: determines the space between terrain vertices in world units for both the X and Z axes.
  • void Terminate(): Deletes any dynamically allocated memory and deletes textures and vertex buffer objects that are stored in GPU memory. This method is implicitly called when the object is destructed.
  • bool LoadHeightmap( std::string filename, unsigned char bitsPerPixel, unsigned int width, unsigned int height ): Load the terrain height map from a RAW file.  This method will accept 8-bit or 16-bit RAW files (Intel byte order LSB,MSB) that have been exported from Terragen Classic.  This function returns true if the height map was successfully loaded.
    • std::string& filename: The relative or absolute path to the RAW file to load.
    • unsigned char bitsPerPixel: The number of bits per pixel of the terrain height map. This will be 8-bit or 16-bit depending on how the height map was generated.
    • unsigned int width: The width of the height map texture in pixels.
    • unsigned int height: The height of the height map texture in pixels.
  • bool LoadTexture( std::string filename, unsigned int textureStage ): The terrain supports up to 3 texture stages (0-2).  Each texture stage will be blended with the next to produce a realistic looking terrain. For each texture stage, you can specify the texture to be used by loading the texture into the texture stage using this method.  This method returns true if the texture was successfully loaded.
    • std::string& filename: The absolute or relative file path to the texture you want to load.
    • unsigned int textureStage: The texture stage you want to bind the texture to.  Valid values are between 0 and 2 for the 3 possible texture stages.  Texture stage 0 is used for the lowest points of the terrain while texture stage 2 is used for the highest points in the terrain.  Texture stage 1 is blended between these two elevations depending on the blending method used (height based blending or slope based blending).
  • float GetHeightAt( glm::vec3 position ): Gets the height of the terrain at a particular point in world space. If the position is not over the terrain, the function will return a very large negative number (-FLT_MAX).
    • glm::vec3 position: The world-space position to test for the terrain height.
  • void Render(): Render the terrain using OpenGL.

The class also defines some protected member functions and the member variables that are used by the terrain class.

The Terrain Source File

In the source file, I will first define a few helper methods that will be used by the terrain class. A few macros will also be defined to control the compilation of the terrain class so that some of the different features can be enabled and disabled.

Includes and Macros

I usually use precompiled headers in my projects to reduce the compile time of my source code so the first thing that you see in all of the source files (.cpp) is the include for the percompiled header file that contains the static includes. I will also define a few macros that can be used to enable or disable certain features of the terrain class (like multi-texturing).

#include "TerrainPCH.h"
#include "Terrain.h"

// Enable mutitexture blending across the terrain
#ifndef ENABLE_MULTITEXTURE
#define ENABLE_MULTITEXTURE 1
#endif

// Enable the blend constants based on the slope of the terrain
#ifndef ENABLE_SLOPE_BASED_BLEND
#define ENABLE_SLOPE_BASED_BLEND 1
#endif 

#define BUFFER_OFFSET(i) ((char*)NULL + (i))

The “TerrainPCH.h” file is the precompiled header file that contains the static headers that are used by this project. If you don’t know how to setup percompiled headers in your project, I would suggest you refer to the Precompiled Headers article on the Fractal eXtreme website available [here].

The ENABLE_MULTITEXTURE macro will allow you to enable and disable the mutitexturing technique used by the Terrain class. If it is disabled by setting this macro to 0, the texture in texture stage 0 will be used to texture the entire terrain.

The ENABLE_SLOPE_BASED_BLEND macro will control how the different textures are blended across the terrain. If slope-based blending is enabled, the textures in texture stage 0 and texture stage 1 (the rock and the grass) will be blended based on the slope of the terrain. If the slope-based blending is disabled by setting this macro to 0, the textures will be blended based on the height of the terrain at that point.

The BUFFER_OFFSET macro is used to alleviate some compilers from producing warnings about conversion from int to pointers. This is used when binding the vertex buffer objects in the render function which expects pointers to arrays but instead we must supply an offset in the vertex buffer object (more on this subject later).

Helper Functions

I will also define a few helper functions that are used to simplify the process.

The GetPercentage function will return the ratio of value between min and max. So if value is less than or equal to min, it will return 0. If value is greater than or equal to max, it will return 1.

inline float GetPercentage( float value, const float min, const float max )
{
    value = glm::clamp( value, min, max );
    return ( value - min ) / ( max - min );
}

The GetFileLength helper function will return the size of a file in bytes. This is used to verify that the size of the RAW texture file used to build the terrain is what we expect.

inline int GetFileLength( std::istream& file )
{
    int pos = file.tellg();
    file.seekg(0, std::ios::end );
    int length = file.tellg();
    // Restore the position of the get pointer
    file.seekg(pos);

    return length;
}

The DeleteVertexBuffer and CreateVertexBuffer helper functions will make sure that the vertex buffer objects are created and destroyed correctly. I created these methods to reduce the amount of typing I had to do later.

inline void DeleteVertexBuffer( GLuint& vboID )
{
    if ( vboID != 0 )
    {
        glDeleteBuffersARB( 1, &vboID );
        vboID = 0;
    }
}

inline void CreateVertexBuffer( GLuint& vboID )
{
    // Make sure we don't loose the reference to the previous VBO if there is one
    DeleteVertexBuffer( vboID );
    glGenBuffersARB( 1, &vboID );
}

The DeleteTexture and CreateTexture convenience methods do the same thing as the create, delete vertex buffer methods, but then for textures.

inline void DeleteTexture( GLuint& texID )
{
    if ( texID != 0 )
    {
        glDeleteTextures( 1, &texID );
        texID = 0;
    }
}

inline void CreateTexture( GLuint& texID )
{
    DeleteTexture( texID );
    glGenTextures(1, &texID );
}

The GetHeightValue helper function is used to translate the incoming char data array into a floating point value in the range [0...1]. The method can handle 8-bit (1 byte), 16-bit (2 byte) and 32-bit (4 byte) value encoding. The function assumes the data is encoded with the Intel standard byte encoding of little-endian which means that the lower-order bytes are stored before the higher order bytes (Least significant byte, Most significant byte or LSB,MSB). If you wanted to load height maps that are stored MSB,LSB, you would have to reverse the array indices for values that read more than 1 byte. In the project zip file, I supply both a 8-bit height map and 16-bit Intel byte order height map textures.

inline float GetHeightValue( const unsigned char* data, unsigned char numBytes )
{
    switch ( numBytes )
    {
    case 1:
        {
            return (unsigned char)(data[0]) / (float)0xff;
        }
        break;
    case 2:
        {
            return (unsigned short)(data[1] << 8 | data[0] ) / (float)0xffff;
        }
        break;
    case 4:
        {
            return (unsigned int)(data[3] << 24 | data[2] << 16 | data[1] << 8 | data[0] ) / (float)0xffffffff;
        }
        break;
    default:
        {
            assert(false);  // Height field with non standard pixle sizes
        }
        break;
    }

    return 0.0f;
}
I will spare you the contents of the constructor and destructor for this class as they are pretty standard methods. Let’s just get right into the important functions.

The Terrain::LoadTexture Method

This terrain class will be able to display at most 3 textures and will blend between the three. This may be enough in most cases. The 3 textures will each be used in their own texture stage. Generally speaking the first texture stage (texture stage 0) will be used for the textures that appear on the lowest level of the terrain (in my demo I use a grass texture) and the second texture (texture stage 1) will be used for the middle heights in the terrain (stone texture) and the third texture (texture stage 2) will be used for the highest level of the terrain (snow texture).

bool Terrain::LoadTexture( const std::string& filename, unsigned int textureStage )
{
    assert( textureStage < m_uiNumTextures );
    DeleteTexture( m_GLTextures[textureStage] );

    m_GLTextures[textureStage] = SOIL_load_OGL_texture( filename.c_str(), SOIL_LOAD_AUTO, SOIL_CREATE_NEW_ID, SOIL_FLAG_MIPMAPS );

    if ( m_GLTextures[textureStage] != 0 )
    {
        glBindTexture( GL_TEXTURE_2D, m_GLTextures[textureStage] );
        glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
        glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
        glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT );
        glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT );
        glTexEnvi( GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE );
        glBindTexture( GL_TEXTURE_2D, 0 );
    }

    return ( m_GLTextures[textureStage] != 0 );
}

Using SOIL textures used by OpenGL can quickly and easily be opened and used. If the texture was successfully loaded, the texture parameters that control how it is mapped to the geometry are specified. In short, the pixels of the texture will be linearly blended when fetching from the texture, and if the texture coordinates of the geometry are less than 0 or greater than one, the texture will be repeated across the geometry which is exactly what we need if we want to texture a large terrain with a small texture.

The Terrain::LoadHeightmap Method

The Terrain::LoadHeightmap method is responsible for performing most of the magic that will build the terrain. The method accepts the name of the file that contains the height map as well as the bit-depth (8-bit, 16-bit, or 32-bit) of the height map texture and the width and height of the texture in pixels. Since the RAW image format we will be loading does not contain any header information that would usually be used to store the image dimensions and bit-depth, the user must supply these arguments when the height map is loaded. It is usually a good idea to encode the bit-depth and dimensions of the RAW file directly in the name of the file when the height map is created (I used Terragen Classic to create the height maps used in this demo). For example, an 8-bit height map with a pixel width of 257 and a pixel height of 257 might be name “terrain0-8bbp-257×257.raw”.

bool Terrain::LoadHeightmap( const std::string& filename, unsigned char bitsPerPixel, unsigned int width, unsigned int height )
{
    if( !fs::exists(filename) )
    {
        std::cerr << "Could not find file: " << filename << std::endl;
        return false;
    }

    fs::ifstream ifs;
    ifs.open( filename, fs::ifstream::binary );
    if ( ifs.fail() )
    {
        std::cerr << "Failed to open file: " << filename << std::endl;
        return false;
    }

    const unsigned int bytesPerPixel = bitsPerPixel / 8;
    const unsigned int expectedFileSize = ( bytesPerPixel * width * height );
    const unsigned int fileSize = GetFileLength( ifs );

    if ( expectedFileSize != fileSize )
    {
        std::cerr << "Expected file size [" << expectedFileSize << " bytes] differs from actual file size [" << fileSize << " bytes]" << std::endl;
        return false;
    }

The first part of this function simply verifies the file we are trying to load exists and it is the size we are expecting based on the passed-in parameters.

The next step is to load the height map data from the RAW texture into an unsigned char data array.

    unsigned char* heightMap = new unsigned char[fileSize];
    ifs.read( (char*)heightMap, fileSize);
    if ( ifs.fail() )
    {
        std::cerr << "An error occurred while reading from the height map file: " << filename << std::endl;
        ifs.close();
        delete [] heightMap;
        return false;
    }
    ifs.close();

The only significant thing I want to point out here is that I load the data into an unsigned char array but the ifstream::read function expects the first parameter to be a pointer to signed char. I use an unsigned char array because I expect the values contained in the height map to be in the range [0...255] for an 8-bit height map and [0...65535] for a 16-bit height map.

Now that we have the height map data we can use it to build the vertex buffer. First, we must be sure that the vertex, color, texture, and normal buffers are sized correctly to store the data.

    unsigned int numVerts = width * height;
    m_PositionBuffer.resize( numVerts );
    m_ColorBuffer.resize( numVerts );
    m_NormalBuffer.resize( numVerts );
    m_Tex0Buffer.resize( numVerts );

    m_HeightmapDimensions = glm::uivec2(width, height);

    // Size of the terrain in world units
    float terrainWidth = ( width - 1 ) * m_fBlockScale;
    float terrainHeight = ( height - 1 ) * m_fBlockScale;

    float halfTerrainWidth = terrainWidth * 0.5f;
    float halfTerrainHeight = terrainHeight * 0.5f;

Since we will define one vertex for every pixel of the height map, the buffers will be sized ( width x height ) but the dimensions of the terrain in world units will go from [0...(size-1)] so the terrainWidth and terrainHeight will store the width and height of the terrain in world units. We want to center the terrain around the local origin to the terrain dimensions will actually be [-halfTerrainWidth...halfTerrainWidth] in the X axis and [-halfTerrainHeight...halfTerrainHeight] in the Z axis.

    for ( unsigned int j = 0; j < height; ++j )
    {
        for ( unsigned i = 0; i < width; ++i )
        {
            unsigned int index = ( j * width ) + i;
            assert( index * bytesPerPixel < fileSize );
            float heightValue = GetHeightValue( &heightMap[index * bytesPerPixel], bytesPerPixel );

            float S = ( i / (float)(width - 1) );
            float T = ( j / (float)(height - 1) );

            float X = ( S * terrainWidth ) - halfTerrainWidth;
            float Y = heightValue * m_fHeightScale;
            float Z = ( T * terrainHeight ) - halfTerrainHeight;

            // Blend 3 textures depending on the height of the terrain
            float tex0Contribution = 1.0f - GetPercentage( heightValue, 0.0f, 0.75f );
            float tex2Contribution = 1.0f - GetPercentage( heightValue, 0.75f, 1.0f );

            m_NormalBuffer[index] = glm::vec3(0);
            m_PositionBuffer[index] = glm::vec3( X, Y, Z );
#if ENABLE_MULTITEXTURE
            m_ColorBuffer[index] = glm::vec4( tex0Contribution, tex0Contribution, tex0Contribution, tex2Contribution );
#else
            m_ColorBuffer[index] = glm::vec4(1.0f);
#endif
            m_Tex0Buffer[index] = glm::vec2( S, T );
        }
    }

For each pixel of the height map we will first get the height value from the height map data and convert it to the range [0...1] using the GetHeightValue method described earlier.

The texture coordinate for the terrain will range from (0,0) in the top-left corner to (1,1) in the bottom right corner. If we want to tile the texture across the terrain, we’ll use the texture matrix to scale the texture coordinates for each texture stage to achieve the tiled texture effect we want.

On line 218-220 the vertex position in world units are computed and if multi-texturing is enabled, the texture blending ratios are computed based on the current height of the vertex. The blending ratios are stored in the color component of the vertex to be used by the multi-texture extension. The blending ratios will be explained in more detail when we setup the texture stages in the Terrain::Render method.

Once we have the vertex positions and texture coordinates computed we still need to generate the index buffer and the vertex normals that will be used on the terrain.

    std::cout << "Terrain has been loaded!" << std::endl;
    delete [] heightMap;

    GenerateIndexBuffer();
    GenerateNormals();
    GenerateVertexBuffers();

    return true;
}

Since we don’t need the height map data, the temporary buffer is deleted and the index buffer and normal buffers will be created. The GenerateVertexBuffers method will create and populate the vertex buffer objects that are used to store the data that has just been generated in GPU memory for faster rendering.

The Terrain::GenerateIndexBuffer Method

Since the vertices of the terrain mesh can be shared with as many as 6 triangles, we use an index buffer to reduce the number of vertices that will be required to fully render the terrain. The index buffer will be built by constructing two triangles who’s vertices are arranged in a counter-clockwise winding order (counter-clockwise winding order is the default for front-facing polygons in OpenGL). The “top” triangle will be referred to as “T0″ and the “bottom” triangle will be referred to as “T1″. If we assume the top-left vertex of the quad is “V0″, the top-right is “V1″, the bottom-left is “V2″ and the bottom-right is “V3″ then:

  • T0 = ( V0, V3, V1 )
  • T1 = ( V0, V2, V3 )

As you can see in this really amazing programmer art shown below:

Terrain Vertex Winding Order

Terrain Vertex Winding Order

void Terrain::GenerateIndexBuffer()
{
    if ( m_HeightmapDimensions.x < 2 || m_HeightmapDimensions.y < 2 )
    {
        // Terrain hasn't been loaded, or is of an incorrect size
        return;
    }

    const unsigned int terrainWidth = m_HeightmapDimensions.x;
    const unsigned int terrainHeight = m_HeightmapDimensions.y;

    // 2 triangles for every quad of the terrain mesh
    const unsigned int numTriangles = ( terrainWidth - 1 ) * ( terrainHeight - 1 ) * 2;

    // 3 indices for each triangle in the terrain mesh
    m_IndexBuffer.resize( numTriangles * 3 );

    unsigned int index = 0; // Index in the index buffer
    for (unsigned int j = 0; j < (terrainHeight - 1); ++j )
    {
        for (unsigned int i = 0; i < (terrainWidth - 1); ++i )
        {
            int vertexIndex = ( j * terrainWidth ) + i;
            // Top triangle (T0)
            m_IndexBuffer[index++] = vertexIndex;                           // V0
            m_IndexBuffer[index++] = vertexIndex + terrainWidth + 1;        // V3
            m_IndexBuffer[index++] = vertexIndex + 1;                       // V1
            // Bottom triangle (T1)
            m_IndexBuffer[index++] = vertexIndex;                           // V0
            m_IndexBuffer[index++] = vertexIndex + terrainWidth;            // V2
            m_IndexBuffer[index++] = vertexIndex + terrainWidth + 1;        // V3
        }
    }
}

Before we can start populating the index buffer, we’ll reserve the required number of index entries that will be populated (line 262). For every quad, 2 triangles will be generated based on the method described above.

The Terrain::GenerateNormals Method

If you followed the article on the MD5 Mesh loading and animation then you may remember the part about the normal generation. We are going to use the exact same algorithm to generate the normals for the terrain. The pseudo-code for the normal generation looks like this:

For every triangle of the mesh
    Compute the triangle normal by the cross-product of the triangle edges
    Add the computed normal to each of the triangle's vertices

For every vertex of the mesh
    Normalize the vertex normal

This is a two-step approach that requires you to loop over the entire index buffer, then another loop to normalize the normals in the vertex buffer.

void Terrain::GenerateNormals()
{
    for ( unsigned int i = 0; i < m_IndexBuffer.size(); i += 3 )
    {
        glm::vec3 v0 = m_PositionBuffer[ m_IndexBuffer[i + 0] ];
        glm::vec3 v1 = m_PositionBuffer[ m_IndexBuffer[i + 1] ];
        glm::vec3 v2 = m_PositionBuffer[ m_IndexBuffer[i + 2] ];

        glm::vec3 normal = glm::normalize( glm::cross( v1 - v0, v2 - v0 ) );

        m_NormalBuffer[ m_IndexBuffer[i + 0] ] += normal;
        m_NormalBuffer[ m_IndexBuffer[i + 1] ] += normal;
        m_NormalBuffer[ m_IndexBuffer[i + 2] ] += normal;
    }

    const glm::vec3 UP( 0.0f, 1.0f, 0.0f );
    for ( unsigned int i = 0; i < m_NormalBuffer.size(); ++i )
    {
        m_NormalBuffer[i] = glm::normalize( m_NormalBuffer[i] );

#if ENABLE_SLOPE_BASED_BLEND
        float fTexture0Contribution = glm::saturate( glm::dot( m_NormalBuffer[i], UP ) - 0.1f );
        m_ColorBuffer[i] = glm::vec4( fTexture0Contribution, fTexture0Contribution, fTexture0Contribution, m_ColorBuffer[i].w );
#endif

    }
}

In the first loop, we iterate over the index buffer to find the 3 vertices of each triangle and compute the normal by taking the cross-product of two of the triangle edges. We must normalize this result to ensure that triangles with larger surface areas (and thus a longer normal vector) don’t influence the final vertex normal more than triangles with smaller surface area. If you need a refresher in the meaning of the cross product and dot products, you can refer to my article on Vector Operations.

In the next loop, we simply normalize the summed normals from the previous step. Since we can also use the normal to determine the slop of the vertex at that point, we can use it to compute the blending factor between the different textures on the terrain. In this case, the texture in texture stage 0 (the grassy texture) will be applied to relatively flat surfaces and the texture in texture stage 1 (the rock texture) will be applied to relatively sloped surfaces. This way we can achieve much more realistic look to our terrain than simply blending by the height of the vertex. To see the difference between the height based blending and the slope based blending, you can change the value of the ENABLE_SLOPE_BASED_BLEND macro setting it to 0 to disable the slope based blending. I think you will agree with me that the slope based blending is visually better than height based blending.

The Terrain::GenerateVertexBuffers Method

To speed up rendering performance we can use vertex buffer objects (provided by the GL_ARB_vertex_buffer_object extension to store the buffer data directly in GPU memory. This will eliminate the need to transfer the vertex buffer data to the GPU every frame. Since the geometry for the terrain will generally not be modified between frames, it is an ideal candidate for this performance improvement. The Terrain::GenerateVertexBuffers method is used to generate and populate these vertex buffers.

void Terrain::GenerateVertexBuffers()
{
    // First generate the buffer object ID's
    CreateVertexBuffer(m_GLVertexBuffer);
    CreateVertexBuffer(m_GLNormalBuffer);
    CreateVertexBuffer(m_GLColorBuffer);
    CreateVertexBuffer(m_GLTex0Buffer);
    CreateVertexBuffer(m_GLTex1Buffer);
    CreateVertexBuffer(m_GLTex2Buffer);
    CreateVertexBuffer(m_GLIndexBuffer);

    // Copy the host data into the vertex buffer objects
    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLVertexBuffer );
    glBufferDataARB( GL_ARRAY_BUFFER_ARB, sizeof(glm::vec3) * m_PositionBuffer.size(), &(m_PositionBuffer[0]), GL_STATIC_DRAW_ARB ); 

    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLColorBuffer );
    glBufferDataARB( GL_ARRAY_BUFFER_ARB, sizeof(glm::vec4) * m_ColorBuffer.size(), &(m_ColorBuffer[0]), GL_STATIC_DRAW_ARB ); 

    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLNormalBuffer );
    glBufferDataARB( GL_ARRAY_BUFFER_ARB, sizeof(glm::vec3) * m_NormalBuffer.size(), &(m_NormalBuffer[0]), GL_STATIC_DRAW_ARB ); 

    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLTex0Buffer );
    glBufferDataARB( GL_ARRAY_BUFFER_ARB, sizeof(glm::vec2) * m_Tex0Buffer.size(), &(m_Tex0Buffer[0]), GL_STATIC_DRAW_ARB ); 

    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLTex1Buffer );
    glBufferDataARB( GL_ARRAY_BUFFER_ARB, sizeof(glm::vec2) * m_Tex0Buffer.size(), &(m_Tex0Buffer[0]), GL_STATIC_DRAW_ARB ); 

    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLTex2Buffer );
    glBufferDataARB( GL_ARRAY_BUFFER_ARB, sizeof(glm::vec2) * m_Tex0Buffer.size(), &(m_Tex0Buffer[0]), GL_STATIC_DRAW_ARB ); 

    glBindBufferARB( GL_ELEMENT_ARRAY_BUFFER_ARB, m_GLIndexBuffer );
    glBufferDataARB( GL_ELEMENT_ARRAY_BUFFER_ARB, sizeof(GLuint) * m_IndexBuffer.size(), &(m_IndexBuffer[0]), GL_STATIC_DRAW_ARB );

    glBindBuffer( GL_ARRAY_BUFFER_ARB, 0 );
    glBindBuffer( GL_ELEMENT_ARRAY_BUFFER_ARB, 0 );

}

When we are filling a buffer that will be used for vertex data (position, color, normal, texture coordinate) where there will be one element per vertex, we use the GL_ARRAY_BUFFER_ARB target parameter. When defining an index buffer, we use the GL_ELEMENT_ARRAY_BUFFER_ARB target parameter. The GL_STATIC_DRAW_ARB usage parameter specifies how the vertex buffer object will be used accessed. Since we will only fill the buffer once and after that, the data will remain static and the data will only be used for drawing, the GL_STATIC_DRAW_ARB usage provides the best optimization for our needs.

The Terrain::GetHeightAt Method

In some cases it may be necessary to determine the height of the terrain at a particular point in space. This is used for operations like terrain collision when we have a character (or a camera) that moves over the terrain. Of course we don’t want the character or the camera to fall through the terrain.

To find the height of the terrain we first must find out which triangle we are over. Then we use a form of bi-linear interpolation over the vertices of the triangle to find the exact height of the point we are currently over. Thats the easy explanation of how it will work, now let’s see the code.

float Terrain::GetHeightAt( const glm::vec3& position )
{
    float height = -FLT_MAX;
    // Check if the terrain dimensions are valid
    if ( m_HeightmapDimensions.x < 2 || m_HeightmapDimensions.y < 2 ) return height;

    // Width and height of the terrain in world units
    float terrainWidth = ( m_HeightmapDimensions.x - 1) * m_fBlockScale;
    float terrainHeight = ( m_HeightmapDimensions.y - 1) * m_fBlockScale;
    float halfWidth = terrainWidth * 0.5f;
    float halfHeight = terrainHeight * 0.5f;

    // Multiple the position by the inverse of the terrain matrix 
    // to get the position in terrain local space
    glm::vec3 terrainPos = glm::vec3( m_InverseLocalToWorldMatrix * glm::vec4(position, 1.0f) );
    glm::vec3 invBlockScale( 1.0f/m_fBlockScale, 0.0f, 1.0f/m_fBlockScale );

    // Calculate an offset and scale to get the vertex indices
    glm::vec3 offset( halfWidth, 0.0f, halfHeight );

    // Get the 4 vertices that make up the triangle we're over
    glm::vec3 vertexIndices = ( terrainPos + offset ) * invBlockScale;

    int u0 = (int)floorf(vertexIndices.x);
    int u1 = u0 + 1;
    int v0 = (int)floorf(vertexIndices.z);
    int v1 = v0 + 1;

    if ( u0 >= 0 && u1 < m_HeightmapDimensions.x && v0 >= 0 && v1 < m_HeightmapDimensions.y )
    {                    
        glm::vec3 p00 = m_PositionBuffer[ ( v0 * m_HeightmapDimensions.x ) + u0 ];    // Top-left vertex
        glm::vec3 p10 = m_PositionBuffer[ ( v0 * m_HeightmapDimensions.x ) + u1 ];    // Top-right vertex
        glm::vec3 p01 = m_PositionBuffer[ ( v1 * m_HeightmapDimensions.x ) + u0 ];    // Bottom-left vertex
        glm::vec3 p11 = m_PositionBuffer[ ( v1 * m_HeightmapDimensions.x ) + u1 ];    // Bottom-right vertex

        // Which triangle are we over?
        float percentU = vertexIndices.x - u0;
        float percentV = vertexIndices.z - v0;

        glm::vec3 dU, dV;
        if (percentU > percentV)
        {   // Top triangle
            dU = p10 - p00;
            dV = p11 - p10;
        }
        else
        {   // Bottom triangle
            dU = p11 - p01;
            dV = p01 - p00;
        }

        glm::vec3 heightPos = p00 + ( dU * percentU ) + ( dV * percentV );
        // Convert back to world-space by multiplying by the terrain's world matrix
        heightPos = glm::vec3( m_LocalToWorldMatrix * glm::vec4(heightPos, 1) );
        height = heightPos.y;
    }

    return height;
}

Before we can accurately compute the height of the terrain, we must be sure that the world-space position parameter is transformed into the local space of the terrain. To do this we must multiply the position parameter (expressed in world space) by the inverse of the terrain’s local to world space matrix. This is done on line 359.

Since we built the terrain vertices in the range [-halfTerrainWidth...halfTerrainWidth] and [-halfTerrainHeight...halfTerrainHeight] we have to translate the incomming position by (halfTerrainWidth, 0, halfTerrainHeight) and multiply by the inverse of the block scale as shown on line 366. The result is the rational index of the vertices in the 2-dimensional grid of the terrain mesh. The position may lay between two vertex indices in which case the integer part of return value will be the index of the top-left vertex while the fractional part will be the ratio between the top-left and top-right vertex in the X-component and similar for the value in the Z-component (line 366).

The image below shows an example of this. If the position we are trying to find the height at falls directly in between and then the integer part of point is 0 and the fractional part is 0.5 in both the X-component and Z-component. In this case, it is irrelevant if we use the vertices of the top triangle () or the bottom triangle () to calculate the terrain height because the result will be the same.

Terrain Vertex Height

Calculating the ratio between vertices

On line 368-371 the indices of the 4 vertices that make up the quad are computed. If the fractional part of the vertex index in the X-axis is greater than the fractional part of the vertex index in the Z-axis, then we know we are inside the top triangle ( shown in the image above), otherwise we are inside the bottom triangle ( shown in the image above).

On lines 387-388, and 392-393 the change in position between the vertices of the quad are computed and on line 396 the exact position is computed based on the position of the top-left vertex ( shown in the image above) and the ratio between the vertices.

In order to express the computed position in world-space, we must multiply the final position by the local to world matrix of the terrain. The final Y-value is then returned to the caller.

The Terrain::Render Method

Finally we get to the good stuff! Rendering the terrain is performed in the Terrain::Render method.

To render the terrain, we’ll first setup the different texture stages that will be used to perform the multi texturing. Then, we’ll bind the vertex buffer objects that contain the vertex stream data. The terrain will be rendered using indexed element arrays. Then before we’re done, the client states will be restored.

The first thing we’ll do is transform the current model-view matrix by the terrain’s local to world matrix so that we can express the terrain geometry in object space.

void Terrain::Render()
{
    glMatrixMode( GL_MODELVIEW );
    glPushMatrix();
    glMultMatrixf( glm::value_ptr(m_LocalToWorldMatrix) );

Then we need to bind the terrain textures that have been loaded in earlier to the texture stages that will be used to perform the multi texture blending on the terrain. We will also bind the texture coordinate buffer for each texture stage. Each texture stage requires a unique texture coordinate buffer to be applied even if the texture coordinates across the terrain do not change for all the texture stages.

Even if we don’t use the multi-texturing technique, we need to bind at least one texture to texture stage 0 and bind the texture coordinate buffer to the client state.

    //
    // Texture Stage 0
    //
    // Simply output texture0 for stage 0.
    //
    glActiveTextureARB( GL_TEXTURE0_ARB );
    glMatrixMode( GL_TEXTURE );
    glPushMatrix();
    glScalef( 32.0f, 32.0f , 1.0f );

    glEnable( GL_TEXTURE_2D );
    glBindTexture( GL_TEXTURE_2D, m_GLTextures[0] );

    glClientActiveTextureARB(GL_TEXTURE0_ARB);
    glEnableClientState( GL_TEXTURE_COORD_ARRAY );
    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLTex0Buffer );
    glTexCoordPointer( 2, GL_FLOAT, 0, BUFFER_OFFSET(0) );

Each texture stage also defines it’s own texture matrix stack. On line 419, we do a matrix scale on the texture matrix for texture stage 0. This scale will cause the texture to be applied tiled 32 times on both the X and Z axes.

On lines 424-427, the vertex buffer object that contains the texture coordinate stream for texture stage 0 is bound to the texture coordinate pointer for the client state. The BUFFER_OFFSET macro simply converts the integer value “0″ into a pointer value expected by the function parameter. When using the vertex buffer object extension, this value represents the offset to the first element in the vertex buffer object that we are binding. Since there is no other data in the m_GLTex0Buffer buffer, the offset is simply “0″.

If we want to use the multi-texturing technique, then we will need more than a single texture stage. We’ll use texture stage 1 to provide the second texture to the terrain.

#if ENABLE_MULTITEXTURE
    // Disable lighting because it changes the primary color of the vertices that are
    // used for the multitexture blending.
    glDisable( GL_LIGHTING );

    //
    // Texture Stage 1
    //
    // Perform a linear interpolation between the output of stage 0
    // (i.e texture0) and texture1 and use the RGB portion of the vertex's
    // color to mix the two.
    //
    glActiveTextureARB(GL_TEXTURE1_ARB );
    glMatrixMode( GL_TEXTURE );
    glPushMatrix();
    glScalef( 32.0f, 32.0f , 1.0f );

    glEnable( GL_TEXTURE_2D );
    glBindTexture( GL_TEXTURE_2D, m_GLTextures[1] );

    glTexEnvi( GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE_ARB );
    glTexEnvi( GL_TEXTURE_ENV, GL_COMBINE_RGB_ARB, GL_INTERPOLATE_ARB );

    glTexEnvi( GL_TEXTURE_ENV, GL_SOURCE0_RGB_ARB, GL_PREVIOUS_ARB );
    glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND0_RGB_ARB, GL_SRC_COLOR );

    glTexEnvi( GL_TEXTURE_ENV, GL_SOURCE1_RGB_ARB, GL_TEXTURE );
    glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND1_RGB_ARB, GL_SRC_COLOR );

    glTexEnvi( GL_TEXTURE_ENV, GL_SOURCE2_RGB_ARB, GL_PRIMARY_COLOR_ARB );
    glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND2_RGB_ARB, GL_SRC_COLOR );

    glClientActiveTextureARB(GL_TEXTURE1_ARB);
    glEnableClientState( GL_TEXTURE_COORD_ARRAY );
    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLTex1Buffer );
    glTexCoordPointer( 2, GL_FLOAT, 0, BUFFER_OFFSET(0) );

We first switch to the appropriate texture stage using the glActiveTextureARB method. Since each texture stage has it’s own texture matrix, we have to reapply the scale to the texture matrix to allow the texture in texture stage 1 to be tiled across the terrain.

To blend the texture in texture stage 0 and texture stage 1, we’ll use the GL_ARB_texture_env_combine OpenGL extension which provides a new texture environment mode GL_COMBINE_ARB. Quoting directly from the extension specification:

Texture environment combiner settings are specified using the glTexEnvi( GLenum target, GLenum pname, GLint param ) function. The pname parameter may be one of the following values. The values accepted by the param parameter depend on the value of the pname parameter and are described below.

  • GL_TEXTURE_ENV_MODE: The param parameter specifies the texture environment mode. This may be GL_COMBINE_ARB.
  • GL_COMBINE_RGB_ARB, GL_COMBINE_ALPHA_ARB: The param parameter specifies the texture operation to perform and may be:
    • GL_REPLACE
    • GL_MODULATE
    • GL_ADD
    • GL_ADD_SIGNED_ARB
    • GL_SUBTRACT_ARB
    • GL_INTERPOLATE_ARB

    The initial value is GL_MODULATE.

  • GL_SOURCEn_RGB_ARB, GL_SOURCEn_ALPHA_ARB: The param parameter specifies the input source for operand n and may be:
    • GL_PRIMARY_COLOR_ARB
    • GL_TEXTURE
    • GL_CONSTANT_ARB
    • GL_PREVIOUS_ARB

    The initial value for the source input depends on value of the pname parameter.

  • GL_OPERANDn_RGB_ARB: The param parameter specifies the input mapping for the RGB portion of operand n and may be any one of:
    • GL_SRC_COLOR
    • GL_ONE_MINUS_SRC_COLOR
    • GL_SRC_ALPHA
    • GL_ONE_MINUS_SRC_ALPHA
  • GL_OPERANDn_ALPHA_ARB: The param parameter specifies the input mapping for the alpha portion of operand n and may be:
    • GL_SRC_ALPHA
    • GL_ONE_MINUS_SRC_ALPHA
  • GL_RBG_SCALE_ARB, GL_ALPHA_SCALE: The param parameter specifies the scale for the entire texture operation and may be 1.0, 2.0, or 4.0. The initial value is 1.0.

Using the GL_INTERPOLATE_ARB texture operation, the resulting color value will be blended using the following function:

Where is the source color values determined by the combination of GL_SOURCEn_RGB_ARB and GL_OPERANDn_RGB_ARB pname parameters.

For texture stage 1, the final output fragment will be generated by a linear interpolation of the output color from the previous texture stage and the filtered texel color of the current texture stage by the primary color of the incoming fragment (which I suppose is the vertex color).

Mathmatically, that might look something like this:

For the next texture stage (texture stage 2), we will do almost the same operation as we performed on texture stage 1 except instead of interpolating by the source color of the incoming fragment, we will blend by the source alpha of the incoming fragment.

    //
    // Texture Stage 2
    //
    // Perform a linear interpolation between the output of stage 1
    // (i.e texture0 mixed with texture1) and texture2 and use the ALPHA
    // portion of the vertex's color to mix the two.
    //
    glActiveTextureARB( GL_TEXTURE2_ARB );
    glMatrixMode( GL_TEXTURE );
    glPushMatrix();
    glScalef( 32.0f, 32.0f , 1.0f );

    glEnable( GL_TEXTURE_2D );
    glBindTexture( GL_TEXTURE_2D, m_GLTextures[2] );

    glTexEnvi( GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE_ARB );
    glTexEnvi( GL_TEXTURE_ENV, GL_COMBINE_RGB_ARB, GL_INTERPOLATE_ARB );

    glTexEnvi( GL_TEXTURE_ENV, GL_SOURCE0_RGB_ARB, GL_PREVIOUS_ARB );
    glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND0_RGB_ARB, GL_SRC_COLOR );

    glTexEnvi( GL_TEXTURE_ENV, GL_SOURCE1_RGB_ARB, GL_TEXTURE );
    glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND1_RGB_ARB, GL_SRC_COLOR );

    glTexEnvi( GL_TEXTURE_ENV, GL_SOURCE2_RGB_ARB, GL_PRIMARY_COLOR_ARB );
    glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND2_RGB_ARB, GL_SRC_ALPHA );

    glClientActiveTextureARB(GL_TEXTURE2_ARB);
    glEnableClientState( GL_TEXTURE_COORD_ARRAY );
    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLTex2Buffer );
    glTexCoordPointer( 2, GL_FLOAT, 0, BUFFER_OFFSET(0) );

The result of this texture environment setup for texture stage 2 is:

If we are not using the multi-texture technique, we can enable texture and lighting on the terrain to achieve “shadows” on terrain polygons that are facing away from the light source.

#else
    glEnable( GL_TEXTURE );
    glEnable( GL_LIGHTING );
#endif

The next step to rendering the terrain is to bind the other vertex buffer objects for the vertex position, color, and normal.

    glEnableClientState( GL_VERTEX_ARRAY );
    glEnableClientState( GL_COLOR_ARRAY );
    glEnableClientState( GL_NORMAL_ARRAY );

    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLVertexBuffer );
    glVertexPointer( 3, GL_FLOAT, 0, BUFFER_OFFSET(0) );
    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLColorBuffer );
    glColorPointer( 4, GL_FLOAT, 0, BUFFER_OFFSET(0) );
    glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_GLNormalBuffer );
    glNormalPointer( GL_FLOAT, 0, BUFFER_OFFSET(0) );

As mentioned earlier, the vertex elements are bound using the GL_ARRAY_BUFFER_ARB target while the index buffer is bound to the GL_ELEMENT_ARRAY_BUFFER_ARB target.

And finally the terrain is actually rendered using the glDrawElements for indexed elements.

    glBindBufferARB( GL_ELEMENT_ARRAY_BUFFER_ARB, m_GLIndexBuffer );
    glDrawElements( GL_TRIANGLES, m_IndexBuffer.size(), GL_UNSIGNED_INT, BUFFER_OFFSET(0) );

And so we don’t confuse the draw calls in another method, we must restore the states.

    glDisableClientState( GL_NORMAL_ARRAY );
    glDisableClientState( GL_COLOR_ARRAY );
    glDisableClientState( GL_VERTEX_ARRAY );

#if ENABLE_MULTITEXTURE
    glActiveTextureARB(GL_TEXTURE2_ARB);
    glPopMatrix();
    glDisable(GL_TEXTURE_2D);
    glClientActiveTextureARB(GL_TEXTURE2_ARB);
    glDisableClientState( GL_TEXTURE_COORD_ARRAY );

    glActiveTextureARB(GL_TEXTURE1_ARB);
    glPopMatrix();
    glDisable(GL_TEXTURE_2D);
    glClientActiveTextureARB(GL_TEXTURE1_ARB);
    glDisableClientState( GL_TEXTURE_COORD_ARRAY );
#endif

    glActiveTextureARB(GL_TEXTURE0_ARB);
    glPopMatrix();
    glDisable(GL_TEXTURE_2D);
    glClientActiveTextureARB(GL_TEXTURE0_ARB);
    glDisableClientState( GL_TEXTURE_COORD_ARRAY );

    glBindBuffer( GL_ARRAY_BUFFER_ARB, 0 );
    glBindBuffer( GL_ELEMENT_ARRAY_BUFFER_ARB, 0 );

    glMatrixMode( GL_MODELVIEW );
    glPopMatrix();

Which should produce the beautiful terrain shown in the video below.

Resources

To generate the height map I used a tool called Terragen Classic which can be downloaded for free from Planetside Software [http://www.planetside.co.uk/].

The textures used on the terrain I just googled “stone terrain texture”, “grass terrain texture”, and “snow terrain texture” and pretty much grabbed the first ones I found. They are being used for educational purposes so I hope the owners don’t mind.

Same thing for the skydome texture. I did a search for “skydome texture” and pretty much took the one that worked best.

Beginning OpenGL Game Programming - Second Edition (2009)

Beginning OpenGL Game Programming (Second Edition) (2009). Luke Benstead with Dave Astle and Kevin Hawkins. Course Technology.

Download the Source

You can download the source code for this demo here:

[TerrainDemo.zip]

One thought on “Multi-textured Terrain in OpenGL

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>