Tutorial 68: Terrain Rendering
What you’ll learn
- Turning heightmap pixels into a vertex grid with an index buffer.
- Computing per-vertex normals so lighting works.
- Blending grass, rock and snow textures by height and slope.
- Querying terrain height for collision.
- Staying inside the graphics profile’s buffer, index and texture limits by chunking the mesh.
Before you start — Tutorial 38: Vertex Buffers and Index Buffers (the mesh buffers), Tutorial 08: Loading and Drawing Textures (reading heightmap pixels) and Tutorial 52: Writing Custom Shaders (ShaderEffect) (the blend shader). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only SDL_RENDERER throws on 3D calls by default, and STUB draws nothing. The BasicEffect path below runs on every 3D renderer that can draw lit, textured VertexPositionNormalTexture geometry; the optional blend shader additionally needs a renderer that executes a custom ShaderEffect (see the note before the blend shader).
Requirements: graphics-profile limits. CNA’s default GraphicsProfile is Reach, and this snapshot enforces its ceilings on every renderer. Terrain is where you hit them: a single mesh for a whole heightmap breaks three of them at once.
- 32-bit indices are HiDef-only.
IndexElementSize::ThirtyTwoBitsthrowsNotSupportedExceptionunder Reach, and 16-bit indices address at most 65,536 vertices. - Primitives per draw: 65,535 on Reach, 1,048,575 on HiDef. A 1,025 × 1,025 grid already has about 2.1 million triangles, more than one HiDef draw can take.
- Buffer size: a single vertex or index buffer is capped at 67,108,863 bytes on every profile (about 2.09 million 32-byte vertices).
- Texture size: a
Texture2Dedge is at most 2048 on Reach and 4096 on HiDef, so a 4097 × 4097 heightmap cannot even be loaded as a texture.
The code below therefore builds the terrain as a grid of chunks, each with its own vertex buffer and 16-bit index buffer. That fits every limit above, runs on the default Reach profile, and gives you a natural unit for frustum culling and LOD. Only a heightmap larger than 2048 × 2048 needs HiDef, requested in the Game constructor before Initialize() applies your preferences (or tile several smaller heightmaps and stay on Reach):
TerrainGame() : graphics_(this) {
graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
// Or for the whole project, before the Game is constructed:
// CNA::SetProjectGraphicsProfileEXT(GraphicsProfile::HiDef); // "CNA/ProjectGraphicsProfile.hpp"
}
The full list of profile ceilings, and the errors you will see when one is exceeded, is in Tutorial 152: Reach vs HiDef.
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/ShaderEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionNormalTexture.hpp"
#include <algorithm>
#include <cstdint>
#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)
// Baseline: lit, textured BasicEffect (grass only). Works on every 3D renderer.
void Draw(const Matrix& view, const Matrix& projection);
// Optional: the grass/rock/snow blend as a custom ShaderEffect (see below).
void DrawBlended(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:
// One vertex buffer and one 16-bit index buffer per chunk of the terrain.
// 64 x 64 cells = 65 x 65 = 4,225 vertices and 8,192 triangles: far inside
// every profile limit, and a natural unit for culling and LOD.
struct Chunk {
std::unique_ptr<VertexBuffer> vb;
std::unique_ptr<IndexBuffer> ib;
int vertexCount = 0;
int primitiveCount = 0;
};
static constexpr int kChunkCells = 64;
void LoadHeightmap(GraphicsDevice& gd, const std::string& path);
void CalculateNormals(); // fills normals_ (must run before BuildBuffers)
void BuildBuffers(GraphicsDevice& gd);
int width_ = 0;
int height_ = 0;
float worldScale_ = 1.0f;
float heightScale_ = 1.0f;
std::vector<float> heightData_; // row-major, [z * width + x]
std::vector<Vector3> normals_; // same indexing as heightData_
std::vector<Chunk> chunks_;
std::unique_ptr<BasicEffect> effect_;
std::unique_ptr<ShaderEffect> blendEffect_; // null where custom shaders are unavailable
std::unique_ptr<Texture2D> grassTexture_;
std::unique_ptr<Texture2D> rockTexture_;
std::unique_ptr<Texture2D> snowTexture_;
GraphicsDevice* gd_ = nullptr;
};
Loading the heightmap pixel data
Texture2D::GetData returns Color texels. 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. Remember that the heightmap is loaded as a Texture2D, so its edges are capped at 2048 on Reach and 4096 on HiDef (see Requirements); larger worlds tile several heightmaps or read a raw height file yourself. First the constructor, which ties the pieces together in the right order:
HeightmapTerrain::HeightmapTerrain(GraphicsDevice& gd,
const std::string& heightmapPath,
float worldScale, float heightScale)
: worldScale_(worldScale), heightScale_(heightScale), gd_(&gd) {
effect_ = std::make_unique<BasicEffect>(gd);
grassTexture_ = std::make_unique<Texture2D>("Content/textures/grass.png", gd);
rockTexture_ = std::make_unique<Texture2D>("Content/textures/rock.png", gd);
snowTexture_ = std::make_unique<Texture2D>("Content/textures/snow.png", gd);
LoadHeightmap(gd, heightmapPath);
CalculateNormals(); // before BuildBuffers: the vertices need the normals
BuildBuffers(gd);
}
void HeightmapTerrain::LoadHeightmap(GraphicsDevice& gd,
const std::string& path) {
Texture2D tex(path, gd);
width_ = tex.getWidthProperty();
height_ = tex.getHeightProperty();
// 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].getRProperty() / 255.0f) * heightScale_;
}
}
Building the VertexBuffer and IndexBuffer
Each grid cell is split into two triangles, and the grid is cut into chunks of 64 × 64 cells that share their border vertices’ positions and normals (so lighting is continuous across chunk seams). Every chunk gets its own vertex buffer and a 16-bit index buffer with indices local to that chunk, which is what keeps the terrain inside the Reach profile. UV coordinates are tiled from the world position, so they are also continuous across chunks and let detail textures repeat without appearing stretched. Both buffers are created with BufferUsage::WriteOnly: the terrain is uploaded once and never read back (WriteOnly only forbids GetData; it does not by itself make a buffer “static”):
void HeightmapTerrain::BuildBuffers(GraphicsDevice& gd) {
for (int z0 = 0; z0 < height_ - 1; z0 += kChunkCells) {
for (int x0 = 0; x0 < width_ - 1; x0 += kChunkCells) {
// Vertices x0..x1 by z0..z1 inclusive; neighbouring chunks share an edge.
const int x1 = std::min(x0 + kChunkCells, width_ - 1);
const int z1 = std::min(z0 + kChunkCells, height_ - 1);
const int cw = x1 - x0 + 1; // vertices across
const int ch = z1 - z0 + 1; // vertices down
std::vector<VertexPositionNormalTexture> vertices;
vertices.reserve(static_cast<size_t>(cw * ch));
for (int z = z0; z <= z1; ++z) {
for (int x = x0; x <= x1; ++x) {
float wx = x * worldScale_;
float wz = z * worldScale_;
float wy = heightData_[z * width_ + x];
// UV tiles every 8 world units
vertices.push_back({
Vector3(wx, wy, wz),
normals_[z * width_ + x], // computed by CalculateNormals()
Vector2(wx / 8.0f, wz / 8.0f)
});
}
}
// Two triangles per quad, indices local to this chunk (at most 65 x 65
// = 4,225 vertices, far below the 16-bit limit of 65,536).
std::vector<uint16_t> indices;
indices.reserve(static_cast<size_t>((cw - 1) * (ch - 1) * 6));
for (int z = 0; z < ch - 1; ++z) {
for (int x = 0; x < cw - 1; ++x) {
uint16_t tl = static_cast<uint16_t>(z * cw + x);
uint16_t tr = static_cast<uint16_t>(tl + 1);
uint16_t bl = static_cast<uint16_t>(tl + cw);
uint16_t br = static_cast<uint16_t>(bl + 1);
// Triangle 1 (clockwise seen from above)
indices.push_back(tl);
indices.push_back(tr);
indices.push_back(bl);
// Triangle 2
indices.push_back(tr);
indices.push_back(br);
indices.push_back(bl);
}
}
Chunk chunk;
chunk.vertexCount = static_cast<int>(vertices.size());
chunk.primitiveCount = static_cast<int>(indices.size()) / 3;
chunk.vb = std::make_unique<VertexBuffer>(
gd, VertexPositionNormalTexture::getVertexDeclarationStatic(),
chunk.vertexCount, BufferUsage::WriteOnly);
chunk.vb->SetData(vertices.data(), chunk.vertexCount);
chunk.ib = std::make_unique<IndexBuffer>(
gd, IndexElementSize::SixteenBits,
static_cast<int>(indices.size()), BufferUsage::WriteOnly);
chunk.ib->SetData(indices.data(), static_cast<int>(indices.size()));
chunks_.push_back(std::move(chunk));
}
}
}
Winding. XNA (and CNA) treat clockwise triangles as front faces and the default state culls counter-clockwise ones. Seen from above (looking down the −Y axis, +X to the right, +Z toward the bottom of the screen) the index order above is clockwise, so the top of the terrain is visible with the default rasterizer state. If your terrain looks like it is missing from above, swap two indices per triangle or set RasterizerState::CullNone while you debug. The winding was worked out by hand, not verified by rendering.
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. The result goes into the normals_ member, which BuildBuffers copies into each vertex (so this function must run first, as the constructor above does):
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 world height and normal to the fragment shader; the fragment shader lerps between grass, rock, and snow textures. This needs a custom ShaderEffect (Tutorial 52), so it only runs on renderers that execute custom shader source, in that renderer’s own language: the GLSL below is ES 3.00 (add #version 300 es and precision highp float; at the top of each file; OPENGLES3 and WEBGL2 run it as written, OPENGL33 wants #version 330 core, VULKAN needs SPIR-V, WEBGPU WGSL, DIRECTX11 HLSL). FNA3D and SOFTWARE report CustomEffects false, and METAL runs custom effects only in SpriteBatch; all three stay on the BasicEffect path below.
// terrain.vert — pass world height and normal to the blend
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
uniform mat4 World;
uniform mat4 View;
uniform mat4 Projection;
out vec3 vWorldNormal;
out vec2 vTexCoord;
out float vHeight;
void main() {
vec4 worldPos = World * vec4(aPosition, 1.0);
gl_Position = Projection * View * worldPos;
vWorldNormal = mat3(World) * aNormal;
vTexCoord = aTexCoord;
vHeight = worldPos.y;
}
// 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
The baseline draw uses BasicEffect with the grass texture and default lighting. setTextureProperty takes a Texture2D*, so pass .get(). Each chunk is one indexed draw, far below the per-draw primitive ceiling:
void HeightmapTerrain::Draw(const Matrix& view, const Matrix& projection) {
effect_->setWorldProperty(Matrix::getIdentityProperty());
effect_->setViewProperty(view);
effect_->setProjectionProperty(projection);
effect_->setLightingEnabledProperty(true);
effect_->EnableDefaultLighting();
effect_->setTextureProperty(grassTexture_.get()); // a pointer, not a reference
effect_->setTextureEnabledProperty(true);
for (auto& chunk : chunks_) { // add frustum culling here for large worlds
gd_->SetVertexBuffer(chunk.vb.get());
gd_->SetIndexBuffer(chunk.ib.get());
for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
pass.Apply();
gd_->DrawIndexedPrimitives(
PrimitiveType::TriangleList,
0, 0,
chunk.vertexCount,
0, chunk.primitiveCount);
}
}
}
To use the grass / rock / snow blend instead, create the ShaderEffect from the two shader files (only where the renderer runs custom shaders), point the samplers at units 0–2 once, and draw the same chunks with it:
// In the constructor, after the textures are loaded (skip on renderers with CustomEffects == false):
if (gd.SupportsCapability(CNA::GraphicsCapability::CustomEffects)) {
blendEffect_ = std::make_unique<ShaderEffect>(
gd,
System::IO::File::ReadAllText("Content/effects/terrain.vert.glsl"),
System::IO::File::ReadAllText("Content/effects/terrain.frag.glsl"));
// The constructor does not throw on a compile failure; check it, and read
// GetCompileErrorEXT() for the compiler log.
if (!blendEffect_->IsEffectValid()) blendEffect_.reset();
if (blendEffect_) {
blendEffect_->Apply();
blendEffect_->SetUniformInt("GrassTexture", 0);
blendEffect_->SetUniformInt("RockTexture", 1);
blendEffect_->SetUniformInt("SnowTexture", 2);
}
}
void HeightmapTerrain::DrawBlended(const Matrix& view, const Matrix& projection) {
if (!blendEffect_) { Draw(view, projection); return; } // fall back to BasicEffect
// ShaderEffect's matrix setter takes raw column-major floats.
auto setMat4 = [&](const char* name, const Matrix& m) {
float cm[16];
m.ToColumnMajor(cm);
blendEffect_->SetUniformMat4(name, cm);
};
blendEffect_->Apply();
setMat4("World", Matrix::getIdentityProperty());
setMat4("View", view);
setMat4("Projection", projection);
blendEffect_->SetUniformFloat("MaxHeight", heightScale_);
blendEffect_->SetTexture(0, *grassTexture_);
blendEffect_->SetTexture(1, *rockTexture_);
blendEffect_->SetTexture(2, *snowTexture_);
for (auto& chunk : chunks_) {
gd_->SetVertexBuffer(chunk.vb.get());
gd_->SetIndexBuffer(chunk.ib.get());
gd_->DrawIndexedPrimitives(PrimitiveType::TriangleList,
0, 0, chunk.vertexCount, 0, chunk.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. CNA has no tessellation stage and no raw-GL escape hatch in its public API (only the
VertexElementUsage::TessellateFactorenumerator exists). Compute is no way round it either:GraphicsCapability::ComputeShaders(CNAEXT) reports whether a renderer could run compute work, but CNA has no public class that dispatches it, so refine terrain on the CPU instead, for example with the level-of-detail meshes of Tutorial 75.
For most games up to 4 km × 4 km at 1 m resolution, a 4097×4097 grid with simple distance-based chunk LOD is sufficient without tessellation. That grid is about 16.8 million vertices, so it lives as roughly 4,100 chunks of the size above, and it cannot come from a single Texture2D (a texture edge is at most 4096 on HiDef, 2048 on Reach): tile several heightmaps or read a raw height file. Drawing only the chunks near the camera (frustum culling with the bounding volumes of Tutorial 44) and swapping distant chunks for lower-resolution meshes is what keeps the draw count manageable.
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);
}