Tutorial 68: Terrain Rendering

CNA Tutorials  ·  3D Rendering

Heightmap loading

A heightmap is a greyscale image where each pixel's intensity encodes the elevation at that grid point. Load it as a Texture2D and then read back the pixel data to extract height values:

// HeightmapTerrain.hpp
#pragma once
#include "Microsoft/Xna/Framework/Graphics/GraphicsDevice.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/IndexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionNormalTexture.hpp"
#include <vector>
#include <memory>
#include <string>

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

class HeightmapTerrain {
public:
    HeightmapTerrain(GraphicsDevice& gd,
                     const std::string& heightmapPath,
                     float worldScale,    // horizontal scale per cell
                     float heightScale);  // vertical scale (max height)

    void Draw(const Matrix& view, const Matrix& projection);

    // Query the height at world position (x, z) via bilinear interpolation
    float GetHeight(float x, float z) const;

private:
    void LoadHeightmap(GraphicsDevice& gd, const std::string& path);
    void BuildBuffers(GraphicsDevice& gd);
    void CalculateNormals();

    int   width_  = 0;
    int   height_ = 0;
    float worldScale_  = 1.0f;
    float heightScale_ = 1.0f;

    std::vector<float> heightData_;  // row-major, [z * width + x]

    std::unique_ptr<VertexBuffer> vb_;
    std::unique_ptr<IndexBuffer>  ib_;
    std::unique_ptr<BasicEffect>  effect_;
    std::unique_ptr<Texture2D>    grassTexture_;
    std::unique_ptr<Texture2D>    rockTexture_;
    std::unique_ptr<Texture2D>    snowTexture_;

    int primitiveCount_ = 0;
    GraphicsDevice* gd_ = nullptr;
};

Loading the heightmap pixel data

Texture2D::GetData returns raw RGBA bytes. Because the image is greyscale, the R channel equals luminance. Divide by 255 to get a 0..1 float and multiply by heightScale to get world units:

void HeightmapTerrain::LoadHeightmap(GraphicsDevice& gd,
                                      const std::string& path) {
    Texture2D tex(path, gd);
    width_  = tex.getWidth();
    height_ = tex.getHeight();

    // Read raw RGBA pixel data
    std::vector<Color> pixels(static_cast<size_t>(width_ * height_));
    tex.GetData(pixels.data(), static_cast<int>(pixels.size()));

    heightData_.resize(pixels.size());
    for (int i = 0; i < static_cast<int>(pixels.size()); ++i) {
        // Use red channel as luminance (0–255 → 0.0–1.0)
        heightData_[i] = (pixels[i].R / 255.0f) * heightScale_;
    }
}

Building the VertexBuffer and IndexBuffer

Each grid cell is split into two triangles. The index buffer uses a standard grid topology. UV coordinates are tiled to allow detail textures to repeat without appearing stretched:

void HeightmapTerrain::BuildBuffers(GraphicsDevice& gd) {
    std::vector<VertexPositionNormalTexture> vertices;
    vertices.reserve(static_cast<size_t>(width_ * height_));

    for (int z = 0; z < height_; ++z) {
        for (int x = 0; x < width_; ++x) {
            float wx = x * worldScale_;
            float wz = z * worldScale_;
            float wy = heightData_[z * width_ + x];

            // UV tiles every 8 world units
            float u = wx / 8.0f;
            float v = wz / 8.0f;

            vertices.push_back({
                Vector3(wx, wy, wz),
                Vector3::Zero,  // normals computed below
                Vector2(u, v)
            });
        }
    }

    // Calculate smooth vertex normals before uploading
    CalculateNormals();  // fills vertices[i].Normal in-place
    // (pass vertices by ref to CalculateNormals in real code)

    vb_ = std::make_unique<VertexBuffer>(
        gd, VertexPositionNormalTexture::VertexDeclaration,
        static_cast<int>(vertices.size()), BufferUsage::None);
    vb_->SetData(vertices.data(), static_cast<int>(vertices.size()));

    // Build index buffer — two triangles per quad
    std::vector<uint32_t> indices;
    indices.reserve(static_cast<size_t>((width_ - 1) * (height_ - 1) * 6));
    for (int z = 0; z < height_ - 1; ++z) {
        for (int x = 0; x < width_ - 1; ++x) {
            uint32_t tl = z * width_ + x;
            uint32_t tr = tl + 1;
            uint32_t bl = tl + width_;
            uint32_t br = bl + 1;
            // Triangle 1
            indices.push_back(tl);
            indices.push_back(bl);
            indices.push_back(tr);
            // Triangle 2
            indices.push_back(tr);
            indices.push_back(bl);
            indices.push_back(br);
        }
    }
    primitiveCount_ = static_cast<int>(indices.size()) / 3;

    ib_ = std::make_unique<IndexBuffer>(
        gd, IndexElementSize::ThirtyTwoBits,
        static_cast<int>(indices.size()), BufferUsage::None);
    ib_->SetData(indices.data(), static_cast<int>(indices.size()));
}

Normal calculation

Smooth normals are computed by accumulating cross products of the surrounding triangles and then normalising. A simpler but slightly less accurate approach uses the central-difference of adjacent height samples:

void HeightmapTerrain::CalculateNormals() {
    // normals_ matches the heightData_ size
    std::vector<Vector3> normals(heightData_.size(), Vector3::Zero);

    auto H = [&](int x, int z) -> float {
        x = std::clamp(x, 0, width_  - 1);
        z = std::clamp(z, 0, height_ - 1);
        return heightData_[z * width_ + x];
    };

    for (int z = 0; z < height_; ++z) {
        for (int x = 0; x < width_; ++x) {
            // Central difference gradient
            float dx = (H(x + 1, z) - H(x - 1, z)) / (2.0f * worldScale_);
            float dz = (H(x, z + 1) - H(x, z - 1)) / (2.0f * worldScale_);
            // Normal is perpendicular to gradient: (-dx, 1, -dz) normalised
            normals[z * width_ + x] = Vector3::Normalize({-dx, 1.0f, -dz});
        }
    }
    normals_ = std::move(normals);
}

Texture blending (grass / rock / snow)

A common approach is to blend between terrain textures based on height and slope. In the vertex shader pass the normalised height to the fragment shader; the fragment shader lerps between grass, rock, and snow textures:

// terrain.frag — height-based texture blending
uniform sampler2D GrassTexture;
uniform sampler2D RockTexture;
uniform sampler2D SnowTexture;
uniform float     MaxHeight;

in vec3 vWorldNormal;
in vec2 vTexCoord;
in float vHeight;

out vec4 fragColor;

void main() {
    float slope = 1.0 - abs(dot(normalize(vWorldNormal), vec3(0,1,0)));
    float h     = vHeight / MaxHeight;

    vec4 grass = texture(GrassTexture, vTexCoord);
    vec4 rock  = texture(RockTexture,  vTexCoord);
    vec4 snow  = texture(SnowTexture,  vTexCoord);

    // Blend rock in on steep slopes
    vec4 base = mix(grass, rock, smoothstep(0.3, 0.6, slope));
    // Blend snow in at high elevations
    vec4 final = mix(base, snow, smoothstep(0.7, 0.9, h));

    fragColor = final;
}

Draw method

void HeightmapTerrain::Draw(const Matrix& view, const Matrix& projection) {
    effect_->setWorld(Matrix::Identity);
    effect_->setView(view);
    effect_->setProjection(projection);
    effect_->setLightingEnabled(true);
    effect_->setTexture(*grassTexture_);
    effect_->setTextureEnabled(true);

    gd_->setVertexBuffer(*vb_);
    gd_->setIndexBuffer(*ib_);

    for (auto& pass : effect_->getCurrentTechnique().Passes) {
        pass.Apply();
        gd_->DrawIndexedPrimitives(
            PrimitiveType::TriangleList,
            0, 0,
            vb_->getVertexCount(),
            0, primitiveCount_);
    }
}

LOD terrain overview

For large terrains, rendering every vertex at full resolution is impractical. The standard approaches are:

  • Geo-MipMapping — divide the terrain into tiles and choose a lower-resolution mesh for tiles far from the camera. Special edge stitching prevents cracks between tiles at different LOD levels.
  • CDLOD (Continuous Distance-Dependent LOD) — a quadtree-based method that morphs vertices between LOD levels, producing crack-free continuous transitions.
  • GPU tessellation — pass a coarse mesh to the GPU and use tessellation shaders to add detail near the camera. Available via OpenGL 4.0 / Vulkan compute; not yet exposed directly in CNA's Effect system but accessible through raw GL calls in the EASYGL backend.

For most games up to 4 km × 4 km at 1 m resolution, a 4097×4097 heightmap with simple distance-based tile LOD is sufficient without tessellation.

Height query for collision and physics

To find the terrain height at an arbitrary world (x, z) position, compute the fractional grid coordinates and bilinearly interpolate between the four surrounding samples:

float HeightmapTerrain::GetHeight(float wx, float wz) const {
    float gx = wx / worldScale_;
    float gz = wz / worldScale_;
    int x0 = std::clamp(static_cast<int>(gx),     0, width_  - 2);
    int z0 = std::clamp(static_cast<int>(gz),     0, height_ - 2);
    int x1 = x0 + 1, z1 = z0 + 1;
    float fx = gx - x0, fz = gz - z0;

    float h00 = heightData_[z0 * width_ + x0];
    float h10 = heightData_[z0 * width_ + x1];
    float h01 = heightData_[z1 * width_ + x0];
    float h11 = heightData_[z1 * width_ + x1];

    return MathHelper::Lerp(
        MathHelper::Lerp(h00, h10, fx),
        MathHelper::Lerp(h01, h11, fx),
        fz);
}