Landscapes rendering

One of the real strengths of nGENE Tech is its capability of rendering realistic outdoors. So far we were concerned with simple scenes. But now we're going to create nicely looking landscapes (grassy hills) including terrain itself, sky, clouds and some objects on the terrain. Ok, so off we go.

We will start with creating our terrain. Terrain in nGENE is created from a height map, ie. a texture in grayscale. If we specify maximum terrain altitude to eg. 100 meters then if a pixel is black it will mean 0 meters; if it is white - 100. Shades in between goes for values between 0 and 100 linearly. So we should start with creating such a texture:

ITexture* pTexture = TextureManager::getSingleton().createTextureFromFile(L"Terrain", L"terrain_height_map.jpg");

Then we have to create so called terrain descriptor. This TERRAIN_DESC structure, as the name implies, describes basic features of the terrain. But before listing them, I have to tell one more thing. In nGENE Tech terrain consists of smaller quads, called patches. If you have terrain of 2048 x 2048 meters it will probably be built up of 16 x 16 quads of 128 x 128 meters in size. Using patches makes terrain rendering more efficient as:

  1. culling out invisible terrain geometry is simpler. It's much faster to abandon whole patch than testing large bunch of terrain vertices.
  2. applying LOD is also simpler. It is a common practice to assign consistent level of detail to whole patch. Changing LOD is as simple as switching index buffer to a less/more detailed.

Ok now let's move to the mentioned description of TERRAIN_DESC structure. It contains:

  • columns - number of vertices per terrain row
  • rows - number of vertices per terrain column
  • tiles_x - number of patches along x axis
  • tiles_z - number of patches along z axis
  • texScaleX - texture u-coordinate scaling factor
  • texScaleY - texture v-coordinate scaling factor
  • heightScale - height scaling factor
  • step - distance between two neighbouring vertices
  • skirtHeight - height of so called skirt - skirt is used to mask gaps between tiles
  • heights - heights of all vertices
  • LODs - levels of detail

It is how we fill this structure:

TEXTURE_DESC textureDesc = pTexture->getTextureDesc();
TERRAIN_DESC terrainDesc;

terrainDesc.columns = textureDesc.width;
terrainDesc.rows = textureDesc.height;
terrainDesc.heightScale = 0.3f;
terrainDesc.step = 1.0f;
terrainDesc.tiles_x = 32;
terrainDesc.tiles_z = 32;
terrainDesc.skirtSize = 1.0f;

We want to use terrain having as much vertices as there are pixels in the texture to have 1-to-1 mapping. So we first obtain texture description from our height map and then apply its size to columns and rows of descriptor. We set heightScale to 0.3. As white in the height map corresponds to 255 it means that maximum altitude of our terrain is 255 * 0.3 = 76.8 meters. Then we set our terrain to have 32 x 32 patches.

Default terrain would be a bit too edgy so we have to smooth it a bit. A good way to do this is to use a box filter. Box filter for any pixel avarages values of its 8 neigbours and itself. Box filter in nGENE Tech is a template functor FuncFilterBox <typename input_data, typename output_data>:

FuncFilterBox <nGENE::byte, float> boxFilter;
nGENE::byte* pTempHeights = (nGENE::byte*)pTexture->getData();
nGENE::byte* pHeights = new nGENE::byte[textureDesc.width * textureDesc.height];
 
uint index = 0;
for(uint z = 0; z < textureDesc.height; ++z)
{
    for(uint x = 0; x < textureDesc.width; ++x)
        pHeights[z * textureDesc.width + x] = pTempHeights[index++ * pTexture->getBitDepth()]; 
}
NGENE_DELETE_ARRAY(pTempHeights);
terrainDesc.heights = boxFilter(pHeights,    textureDesc.width, textureDesc.height);

Next step is to define LOD - level of detail:

terrainDesc.LODs.push_back(make_pair(1, 40.0f));
terrainDesc.LODs.push_back(make_pair(2, 80.0f));
terrainDesc.LODs.push_back(make_pair(4, 160.0f));
terrainDesc.LODs.push_back(make_pair(8, 250.0f));
terrainDesc.LODs.push_back(make_pair(16, 1e9));

It is done by creating new pairs of two values: step and distance and adding them to the LOD vector in our terrain descriptor.
Step is a value telling us which vertices of the patch should be rendered. 1 stays for "render every vertex", 2 - "render every 2 vertices", 4 - "render every 4 vertices". To avoid problems you should set step to values by which both dimensions of patch are dividible and less than this dimensions. For example. If you have patch of 32 x 32 vertices it is perfectly ok to set step to 1, 2, 4, 8 or 16. But not to 6 or 32. Also the bigger the step, the less processing has to be done by vertex shaders and thus the faster it should work. However, also the results are worse as some level detail popping can occur. Value of 1 should be used for the most detailed LOD as not using this would just mean waste of memory to hold redundant vertices. You should also note that LODs should be added from ones used nearer the camera to the ones used at further distances.

The second value ie. distance implies maximum distance from the camera at which given level detail is used. Minimum distance at which the given LOD will be used is distance value of its predecessor in the LOD vector. So 40.0f above means that the most detailed LOD is used in range 0.0 to 40.0 meters. 80.0f - from 40.0 to 80.0 meters. Value of 1e9 stands for infinity.

Note that if now LODs are specified, detail level won't be changed at all.

Finally we have to create terrain and sets surface to all its 1024 quads. You can use different materials for all the surfaces. However, we are using default one for each of them. Default terrain material uses 4 diffuse and 4 normal textures and applies them to terrain based on slope angle and terrain altitude. That means terrain texturing is procedural at least to some extent. The code below should be self-explanatory:

NodeTerrain* pTerrain = sm->createTerrain(terrainDesc);
pTerrain->setPosition(-256.0f, -9.0f, -256.0f);
Material* matTerrain = MaterialManager::getSingleton().getLibrary(L"default")->getMaterial(L"terrain");
Surface* pSurface = NULL;
for(uint i = 0; i < 1024; ++i)
{
    pSurface = pTerrain->getSurface(i);
    pSurface->setMaterial(matTerrain);
}
 
sm->getRootNode()->addChild(L"Landscape", pTerrain);
NGENE_DELETE_ARRAY(pHeights);

An alternative to stating '1024' explicitly would be calling pTerrain->getSurfacesNum() - 1. This method returns number of surfaces belonging to the NodeVisible object including normals so that's why we subtracted 1.

Also to make terrain "walkable", like in the previous tutorials, we have to create its physics representation:

RigidBody* pActor = pWorld->createTerrain(pTerrain->getTerrainDesc());
pActor->attachNode((NodeVisible*)sm->getNode(L"Landscape"));
pActor->setShapeMaterial(0, *physMat);
pWorld->addRigidBody(L"Terrain", pActor);

If you compile and run this app at this stage you should get something like this:

Tut_2_2_1.jpg

Pretty boring compared to modern games, isn't it? So we're making it more interesting now.

First let's alter App.h file a bit:

#pragma once

#include "FrameworkWin32.h"
#include "ScenePartitionerQuadTree.h"
#include "NodeMeadow.h"
#include "NodeSky.h"
#include "NodeClouds.h"

#include "MyInputListener.h"

using namespace nGENE::Application;
using nGENE::ScenePartitionerQuadTree;
using nGENE::CharacterController;
using nGENE::Nature::NodeClouds;
using nGENE::Nature::NodeMeadow;
using nGENE::Nature::PLANT_DESC;
using nGENE::Nature::NodeSky;

class App: public FrameworkWin32
{
private:
    ScenePartitionerQuadTree* m_pPartitioner;

    MyInputListener* m_pInputListener;

    CharacterController* m_pController;

    NodeSky sky;
    NodeClouds clouds;
    NodeMeadow meadow;

public:
    App(): sky(1000.0f)
    {}
    ~App() {}

    void createApplication();

    CharacterController* getController() const;
};

We only added some includes, usings and objects definitions.

We start by adding grass (meadow in the definition above). For that we use so called distribution maps. Blades are randomly placed on the terrain but only in places for which corresponding pixel's value in the texture is greater than 127.

ITexture* densityMap = TextureManager::getSingleton().createTextureFromFile(L"grass_density_map", L"density_map.jpg");
 
// Create meadow
PLANT_DESC grass;
grass.plantsCount = 35000;
grass.width = 1.5f;
grass.widthSpread = 0.4f;
grass.height = 1.5f;
grass.heightSpread = 0.4f;
grass.undergroundLevel = 0.0f;
grass.material = MaterialManager::getSingleton().getLibrary(L"default")->getMaterial(L"meadow_grass_1");
grass.distributionMap = densityMap;

In the code above we specify parameters of our grass. Avarage width and height of the blades, deviations from these values (spread ones) and a value specifying to what degree plant will be hidden in the ground. Then we set material to grass material and specify distribution map.

We also have to add grass to the meadow object as one meadow can have many billboards and even 3D meshes (eg. stones, trees). Then we regularly add meadow node to the scene manager:

meadow.setCreator(sm);
meadow.setTerrain(pTerrain);
meadow.addPlant(grass);
sm->getRootNode()->addChild(L"Meadow", meadow);
 
TextureManager::getSingleton().removeTexture(L"grass_density_map");

Adding sky and clouds is also simple. Two nodes are required NodeSky and NodeClouds. Each have similar methods and most of them should be familiar to you. A few things to note are:

  • setSimulated() method - this method specifies whether simulation is to be carried or not. If true is passed then sky and clouds will change colours in a very convincing way according to the Preetham's scattering model (it's the most realistic model which can still be used for real time games and visualizations). Using simulation requires setting day length by calling setDayLength() method and passing number of seconds a day should last. Note that this model is not to be used for night sky rendering.
  • as we are not using simulation, we have to set day time to get sky at this particular hour. We do this by creating helper object of TimeDate type. You can specify exact time of day using it. We set it to 15.00 (= 3 PM).
sky.setCreator(sm);
sky.init();
sky.setSimulated(false);
TimeDate time;
time.hours = 15;
sky.setDayTime(time);
sky.setPosition(0.0f, 0.0f, 0.0f);
sm->getRootNode()->addChild(L"Sky", sky);
 
clouds.setSimulated(false);
clouds.setCreator(sm);
clouds.init();
clouds.setPosition(0.0f, 150.0f, 0.0f);
clouds.setDayTime(time);
sm->getRootNode()->addChild(L"Clouds", clouds);

And it is how the terrain looks now. I believe it's much better. Still a lot of tuning and work is required to use it in a game but it's up to your creativity.

Tut_2_2_2.jpg
Tut_2_2_3.jpg

Hope you like it!

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License