Navigate
Search topics across all sections
Procedural Terrain
Sculpting mountains from math. A noise function creates random-looking but smooth height values. Stack multiple layers of noise at different scales (called octaves) and you get the full spectrum: big mountains, medium hills, small ridges, tiny bumps. The result is a realistic digital landscape generated entirely from a single formula.
You write a noise function in the vertex shader and displace the plane. The terrain appears, but it looks like gentle rolling hills with no detail -- no sharp ridges, no small rocks, just smooth blobs.
Terrain has only large-scale features. No fine detail visible regardless of how many vertices the plane has.
Real-world
Imagine you are a digital landscape architect. You start with a perfectly flat field and you need to sculpt mountains. But you do not carve by hand -- you use a recipe.
First pass: take a noise function and use it to create big, gentle hills (high amplitude, low frequency). Second pass: add another layer of noise with double the frequency and half the height -- these are the medium ridges on top of the hills. Third pass: double the frequency again, halve the height again -- now you have small bumps on the ridges.
This is Fractal Brownian Motion (FBM). Each layer is called an octave (like musical octaves, each is double the frequency). The ratio of frequency increase is lacunarity. The ratio of amplitude decrease is gain. Together, they control how rough or smooth the terrain looks. Nature works the same way -- coastlines, clouds, and mountains are all fractal.
How Procedural Terrain Works
From a flat plane to a realistic landscape in four stages.
Noise Function
Smooth pseudo-random values
FBM Loop
Stack octaves of noise
Vertex Displacement
Push vertices up by height
Color by Height
Water, grass, rock, snow
Building the Terrain Step by Step
We will build a gradient noise function, stack it into FBM, and use it to displace a high-resolution plane.
Step 1 -- The noise function
vec2 hash2(vec2 p) {
p = vec2(
dot(p, vec2(127.1, 311.7)),
dot(p, vec2(269.5, 183.3))
);
return -1.0 + 2.0
* fract(sin(p) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p); // integer cell
vec2 f = fract(p); // position in cell
vec2 u = f * f * (3.0 - 2.0 * f); // smooth
// Gradient noise from 4 corners
float a = dot(hash2(i + vec2(0,0)), f - vec2(0,0));
float b = dot(hash2(i + vec2(1,0)), f - vec2(1,0));
float c = dot(hash2(i + vec2(0,1)), f - vec2(0,1));
float d = dot(hash2(i + vec2(1,1)), f - vec2(1,1));
return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
}This gradient noise function divides 2D space into a grid. Each grid corner gets a pseudo-random gradient direction (from hash2). The noise value at any point is the smooth interpolation of dot products between gradients and offset vectors. The result is smooth, continuous, and repeatable -- the same input always gives the same output.
Step 2 -- Fractal Brownian Motion (FBM)
float fbm(vec2 p, float octaves,
float lacunarity, float gain
) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 8; i++) {
if (float(i) >= octaves) break;
value += amplitude * noise(p * frequency);
frequency *= lacunarity; // double freq
amplitude *= gain; // halve amp
}
return value;
}FBM is the secret sauce. Each loop iteration adds a noise layer with higher frequency (smaller features) and lower amplitude (less contribution). Lacunarity controls how fast frequency grows -- 2.0 is standard. Gain controls how fast amplitude shrinks -- 0.5 is standard. More octaves means more detail but more computation.
Step 3 -- Displace vertices and pass height
void main() {
vec2 coord = position.xz * 0.5;
float height = fbm(coord,
uOctaves, uLacunarity, uGain);
height = abs(height) * uElevation;
vec3 pos = position;
pos.y += height;
vHeight = height;
vPosition = position;
gl_Position = projectionMatrix
* modelViewMatrix * vec4(pos, 1.0);
}We sample the FBM using the vertex's XZ position as coordinates. The abs() creates sharp ridges where the noise crosses zero -- this is a technique called “ridged noise” that produces mountain-like peaks. The height is passed as a varying for the fragment shader to use for coloring.
Step 4 -- Color by height with lighting
void main() {
float h = vHeight / uElevation;
// Height-based color bands
vec3 color = vec3(0.1, 0.3, 0.5); // water
color = mix(color, sand, smoothstep(0.0, 0.08, h));
color = mix(color, grass, smoothstep(0.08, 0.2, h));
color = mix(color, rock, smoothstep(0.3, 0.5, h));
color = mix(color, snow,
smoothstep(uSnowLine - 0.1, uSnowLine, h));
// Compute normal from screen-space derivatives
vec3 dx = dFdx(vec3(vPosition.x, vHeight, vPosition.z));
vec3 dz = dFdy(vec3(vPosition.x, vHeight, vPosition.z));
vec3 normal = normalize(cross(dx, dz));
float light = max(dot(normal,
normalize(vec3(1, 2, 1))), 0.0) * 0.6 + 0.4;
color *= light;
gl_FragColor = vec4(color, 1.0);
}Height-based coloring uses cascading smoothstep calls to blend between biome colors: water at the bottom, then sand, grass, rock, and snow at the peaks. The snow line is controllable via a uniform. dFdx/dFdy compute approximate surface normals from the displaced geometry, giving us proper lighting without recomputing normals on the CPU.
What you just learned
Gradient noise creates smooth pseudo-random values by interpolating random gradients at grid corners.
FBM (Fractal Brownian Motion) stacks multiple noise octaves: each doubles frequency and halves amplitude.
Lacunarity controls frequency growth between octaves. Gain controls amplitude decay. Together they shape terrain roughness.
abs(noise) creates 'ridged noise' with sharp mountain peaks where the noise function crosses zero.
dFdx/dFdy in the fragment shader approximate surface normals on displaced geometry without CPU-side recomputation.
Question
Lacunarity and gain work together but have very different effects. What happens if you set lacunarity to 1.0 (instead of the standard 2.0)? And separately, what happens if you set gain to 1.0 (instead of the standard 0.5)?
Think about it...
You set gain to 1.0 instead of 0.5 and the terrain explodes into extreme jagged spikes. Why does a gain of 1.0 break the terrain?
Hint: Think about what gain controls: the amplitude ratio between successive octaves. If gain is 1.0, what happens to the amplitude at each octave?
Try These Challenges
Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.
Try This!
Beginner
Set octaves to 1 — smooth hills
Try This!
Beginner
Set octaves to 8 — detailed mountains
Try This!
Beginner
Toggle wireframe — see the mesh
These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.
float height = noise(position.xz * 0.5);
// Result: gentle rolling hills
// No small details, ridges, or cragsuniform float uOctaves;
for (int i = 0; i < uOctaves; i++) {
// ERROR: comparing int to float
// Won't compile on many GPUs
}// Fragment shader:
// Using original flat plane normal
vec3 normal = vec3(0.0, 0.0, 1.0);
float light = dot(normal, lightDir);
// Lighting is uniform across terrainBest Practices
Start with 5-6 octaves
Beyond 6 octaves, each layer contributes less than 1.5% of the total height. The visual improvement is negligible but the GPU cost is real. Use 5-6 for most terrain and only go higher if the camera gets very close.
Use abs() for ridged terrain
Taking the absolute value of noise creates sharp ridges where the function crosses zero. This produces mountain peaks and valley floors that look much more natural than raw smooth noise.
Recompute normals in fragment
After vertex displacement, the original normals are wrong. Use dFdx/dFdy in the fragment shader to approximate the true surface normal from the displaced height. This is cheaper than recomputing normals on the CPU.
Match segments to octaves
High-frequency noise octaves need enough vertices to resolve. With lacunarity 2.0 and 6 octaves, the highest frequency is 32x the base. Ensure your plane has enough segments to capture it, or the high-frequency detail aliases into noise.