Support me!

018.) Heightmap Pt. 3 - Multiple Layers

Hi there! After a summer break, I'm back to writing OpenGL tutorials and this time I am going to show you, how to render a terrain with multiple texture layers on it with smooth transitions. This allows us to create far more realistic scenes than whatever we have done until now! Actually, it's not even that hard, if you followed other 2 heightmap tutorials, this one should not be that much more difficult from the previous ones . Just read further and you will see .

To implement the algorithm correctly, first we have to have a clear understanding of the concept. Basically what we want is to have several textures within our terrain. Those textures are present up to a certain height and then they start to transition smoothly into another texture. Let's take the terrain from this tutorial as an example. It consists of three textures - rocky texture at the bottom, grass texture in the middle and snow texture on the top. This is the cut of our terrain:

The quality is rather poor, but the concept is important . As you can see, we had to define four values, that I call levels (we could also call it thresholds). First level is 0.2 - up to height 0.2, we just have this rocky terrain, nothing else. After that, transition phase from rocks to grass begins and ends at the second level 0.3, where the transition to grass ends and pure grass begins. Grass goes up to the third level value, which is 0.55 and that's where another transition phase from grass to snow begins. Fourth and the last level - 0.7 is height from which there's only the snow. Snow will go up to the highest point (height 1.0), just as rocks go from the very bottom (height 0.0) up to the first level .

We can now quickly determine the equation of how many levels do we need to define, if we have generally N textures:

- N=1 - 0 levels (doesn't even make sense here to use multi-layered heightmap with 1 texture)
- N=2 - 2 levels (First texture, transition between first and second texture and second texture only)
- N=3 - 4 levels (Our example)
- N - (N-1)*2 levels (try out this equation for these low values of N)

With this equation in mind, we can now implement shader program for rendering heightmaps with multiple layers! It will be a generic shader program allowing you to define (almost) arbitrary number of levels. Why almost? Because our uniform variables will have limited size and you should not go beyond it (but who needs terrain with 100 textures anyway ). You will see it in the code below .

As a first thing, let's analyze the vertex shader. Not much new stuff there, just one important thing (explained below):

#version 440 core

uniform struct

{

mat4 projectionMatrix;

mat4 viewMatrix;

mat4 modelMatrix;

mat3 normalMatrix;

} matrices;

layout(location = 0) in vec3 vertexPosition;

layout(location = 1) in vec2 vertexTexCoord;

layout(location = 2) in vec3 vertexNormal;

smooth out vec2 ioVertexTexCoord;

smooth out vec3 ioVertexNormal;

smooth out float ioHeight;

void main()

{

mat4 mvpMatrix = matrices.projectionMatrix * matrices.viewMatrix * matrices.modelMatrix;

gl_Position = mvpMatrix * vec4(vertexPosition, 1.0);

ioVertexTexCoord = vertexTexCoord;

ioVertexNormal = matrices.normalMatrix*vertexNormal;

ioHeight = vertexPosition.y;

}

uniform struct

{

mat4 projectionMatrix;

mat4 viewMatrix;

mat4 modelMatrix;

mat3 normalMatrix;

} matrices;

layout(location = 0) in vec3 vertexPosition;

layout(location = 1) in vec2 vertexTexCoord;

layout(location = 2) in vec3 vertexNormal;

smooth out vec2 ioVertexTexCoord;

smooth out vec3 ioVertexNormal;

smooth out float ioHeight;

void main()

{

mat4 mvpMatrix = matrices.projectionMatrix * matrices.viewMatrix * matrices.modelMatrix;

gl_Position = mvpMatrix * vec4(vertexPosition, 1.0);

ioVertexTexCoord = vertexTexCoord;

ioVertexNormal = matrices.normalMatrix*vertexNormal;

ioHeight = vertexPosition.y;

}

There is one very important thing to notice and it's outputting of a variable smooth out float ioHeight, which is the input height of a particular vertex. This is then smoothly interpolated to every fragment and we will use it in fragment shader .

Fragment shader is where most of the magic happens and will require a bit of explanation. Let's have a look at it:

#version 440 core

precision highp float;

#include "../lighting/ambientLight.frag"

#include "../lighting/diffuseLight.frag"

#include "../common/utility.frag"

layout(location = 0) out vec4 outputColor;

smooth in vec2 ioVertexTexCoord;

smooth in vec3 ioVertexNormal;

smooth in float ioHeight;

uniform vec4 color;

uniform AmbientLight ambientLight;

uniform DiffuseLight diffuseLight;

uniform sampler2D terrainSampler[16];

uniform float levels[32];

uniform int numLevels;

void main()

{

vec3 normal = normalize(ioVertexNormal);

vec4 textureColor = vec4(0.0);

bool isTextureColorSet = false;

for(int i = 0; i < numLevels && !isTextureColorSet; i++)

{

if(ioHeight > levels[i]) {

continue;

}

int currentSamplerIndex = i / 2;

if(i % 2 == 0) {

textureColor = texture(terrainSampler[currentSamplerIndex], ioVertexTexCoord);

}

else

{

int nextSamplerIndex = currentSamplerIndex+1;

vec4 textureColorCurrent = texture(terrainSampler[currentSamplerIndex], ioVertexTexCoord);

vec4 textureColorNext = texture(terrainSampler[nextSamplerIndex], ioVertexTexCoord);

float levelDiff = levels[i] - levels[i-1];

float factorNext = (ioHeight - levels[i-1]) / levelDiff;

float factorCurrent = 1.0f - factorNext;

textureColor = textureColorCurrent*factorCurrent + textureColorNext*factorNext;

}

isTextureColorSet = true;

}

if(!isTextureColorSet)

{

int lastSamplerIndex = numLevels / 2;

textureColor = texture(terrainSampler[lastSamplerIndex], ioVertexTexCoord);

}

vec4 objectColor = textureColor*color;

vec3 lightColor = sumColors(getAmbientLightColor(ambientLight), getDiffuseLightColor(diffuseLight, normal));

outputColor = objectColor*vec4(lightColor, 1.0);

}

precision highp float;

#include "../lighting/ambientLight.frag"

#include "../lighting/diffuseLight.frag"

#include "../common/utility.frag"

layout(location = 0) out vec4 outputColor;

smooth in vec2 ioVertexTexCoord;

smooth in vec3 ioVertexNormal;

smooth in float ioHeight;

uniform vec4 color;

uniform AmbientLight ambientLight;

uniform DiffuseLight diffuseLight;

uniform sampler2D terrainSampler[16];

uniform float levels[32];

uniform int numLevels;

void main()

{

vec3 normal = normalize(ioVertexNormal);

vec4 textureColor = vec4(0.0);

bool isTextureColorSet = false;

for(int i = 0; i < numLevels && !isTextureColorSet; i++)

{

if(ioHeight > levels[i]) {

continue;

}

int currentSamplerIndex = i / 2;

if(i % 2 == 0) {

textureColor = texture(terrainSampler[currentSamplerIndex], ioVertexTexCoord);

}

else

{

int nextSamplerIndex = currentSamplerIndex+1;

vec4 textureColorCurrent = texture(terrainSampler[currentSamplerIndex], ioVertexTexCoord);

vec4 textureColorNext = texture(terrainSampler[nextSamplerIndex], ioVertexTexCoord);

float levelDiff = levels[i] - levels[i-1];

float factorNext = (ioHeight - levels[i-1]) / levelDiff;

float factorCurrent = 1.0f - factorNext;

textureColor = textureColorCurrent*factorCurrent + textureColorNext*factorNext;

}

isTextureColorSet = true;

}

if(!isTextureColorSet)

{

int lastSamplerIndex = numLevels / 2;

textureColor = texture(terrainSampler[lastSamplerIndex], ioVertexTexCoord);

}

vec4 objectColor = textureColor*color;

vec3 lightColor = sumColors(getAmbientLightColor(ambientLight), getDiffuseLightColor(diffuseLight, normal));

outputColor = objectColor*vec4(lightColor, 1.0);

}

First of all, we have three new uniforms - terrainSampler[16], levels[32] and numLevels. The samplers are used to access terrain textures. levels is an array of those thresholds, where the transitions start / end. Finally, numLevels tells us how many levels have we actually defined (in case of three textures, we would have 4 levels). I chose those sizes of arrays to be big enough - at the moment we are supporting up to 16 textures. If you somehow needed more, increase this number along with number of levels .

Now, there is one thing we need to realize - we are always either in a single texture phase or a transition phase. Those two phases are alternating and we can determine this using a modulo operator and parity of our for loop control variable i. But before it, we have to reach the current level we are in with a current ioHeight.

If we are in a single texture phase (if(i % 2 == 0)), then we simply output the texture of this level, not much to think of here .

Now things get trickier if we are in transition phase. As you can see, I have lots of variables defined there - makes to code easier to read and leaves less room for mistakes. What is done here is that I'm getting the texels of current texture (textureColorCurrent) and next texture (textureColorNext). Then, I proceed with calculating the difference between the start and end of a transition (levelDiff). This value is used to calculate factor, with which the current and next texture will contribute with. Final color is then the sum of the current texture and next texture we transition into, both multiplied by their respective factors.

If we go through the whole for loop without calculating the texture color (if(!isTextureColorSet)), that means only one thing - we are beyond the last level and have to use the last texture up to the maximum height 1.0. That is what the code within the if(!isTextureColorSet) does. At the end of the fragment shader, we just do usual stuff like adding ambient / diffuse light and combining a terrain with a desired color (which is usually white and in this case too) .

With shaders ready, now we can write a function in Heightmap class that does rendering. You can find it below:

void Heightmap::renderMultilayered(const std::vector<std::string>& textureKeys, const std::vector<float> levels) const

{

if (!_isInitialized) {

return;

}

// If there are less than 2 textures, does not even make sense to render heightmap in multilayer way

if (textureKeys.size() < 2) {

return;

}

// Number of levels defined must be correct

if ((textureKeys.size() - 1) * 2 != levels.size()) {

return;

}

// Bind chosen textures first

const auto& tm = TextureManager::getInstance();

auto& heightmapShaderProgram = getMultiLayerShaderProgram();

for (auto i = 0; i < int(textureKeys.size()); i++)

{

tm.getTexture(textureKeys[i]).bind(i);

heightmapShaderProgram[Heightmap::ShaderConstants::terrainSampler(i)] = i;

}

// Set uniform levels

heightmapShaderProgram[Heightmap::ShaderConstants::numLevels()] = int(levels.size());

heightmapShaderProgram[Heightmap::ShaderConstants::levels()] = levels;

// Finally render heightmap

render();

}

{

if (!_isInitialized) {

return;

}

// If there are less than 2 textures, does not even make sense to render heightmap in multilayer way

if (textureKeys.size() < 2) {

return;

}

// Number of levels defined must be correct

if ((textureKeys.size() - 1) * 2 != levels.size()) {

return;

}

// Bind chosen textures first

const auto& tm = TextureManager::getInstance();

auto& heightmapShaderProgram = getMultiLayerShaderProgram();

for (auto i = 0; i < int(textureKeys.size()); i++)

{

tm.getTexture(textureKeys[i]).bind(i);

heightmapShaderProgram[Heightmap::ShaderConstants::terrainSampler(i)] = i;

}

// Set uniform levels

heightmapShaderProgram[Heightmap::ShaderConstants::numLevels()] = int(levels.size());

heightmapShaderProgram[Heightmap::ShaderConstants::levels()] = levels;

// Finally render heightmap

render();

}

This method takes two parameters - texture keys to render with and levels. At the beginning, we are doing some basic validations if the input is correct (number of levels and number of textures must be valid according to our equation) and if it is, we proceed with setting all those uniform variables before rendering. Nothing too difficult in the end I think .

This is what has been achieved today:

I think the result is really beautiful and opens a new horizon of possibilities . There is one visual artifact at transition levels (you can actually see where the transitions begin / end) and I can't get rid of it (trust me, I have really tried), if anyone knew what causes it, let me know .

This tutorial was the last from the heightmap series and my next tutorial will (finally!) be about model loading! I hope you have enjoyed your summer just as I did and you're ready to digest some more of them tutorials . Thank you for reading and stay tuned for another!