Lesson 8 · Essential
Seeds, noise and terrain
A generated world is a function of one number, the seed. How to keep it reproducible, what value, Perlin and simplex noise are, how octaves of noise add detail, and how folds, warps and masks turn a sum of noise into land.
Procedural generation means the game builds content (terrain, dungeons, levels, foliage, plants) from rules and random numbers instead of loading it from disk. This part works through the common techniques on the procgen lab’s own generators. Every one of them runs in your browser and takes one number, the seed, so the same seed always gives the same result, step for step, here and in the lab.
A world is a function of its seed
A generator that calls the platform’s rand() makes a new world every run and can’t make the same one twice. Games want the opposite: a world stored as a single number, seeds players can share, and a bug report that says “seed 1, medium map”. That takes three things.
Your own generator. Use a small seeded PRNG with a fixed algorithm, not the standard library’s, whose algorithm can change between platforms and compiler versions. The lab uses mulberry32, which has 32 bits of state:
struct Rng { // mulberry32
uint32_t a;
float next() { // [0, 1)
uint32_t t = a += 0x6D2B79F5u;
t = (t ^ (t >> 15)) * (t | 1u);
t ^= t + (t ^ (t >> 7)) * (t | 61u);
return float((t ^ (t >> 14)) >> 8) / 16777216.0f;
}
};
One stream per system. If the caves and the trees draw from one generator, adding a tree moves every random number after it and every cave changes. Give each system, and each chunk of a streamed world, its own generator, seeded from a hash of the world seed and its own name and coordinates: Rng{hash(world_seed, CAVES, chunk_x, chunk_y)}. Then a chunk generates the same whether you visit it first or last.
Arithmetic that agrees everywhere. sin, cos and pow aren’t guaranteed to give bit-identical results on every platform, and one different bit can flip a later comparison. The lab keeps them out of everything it counts: noise frequencies are built by repeated multiplication, and nothing that decides a cell uses trig. The determinism lesson covers the rest.
Noise: random, but smooth
rng.next() per pixel gives static. Terrain needs coherent noise: random, but nearby points get similar values. All three classic kinds start from a lattice of whole-number points with something random at each and blend between them.
- Value noise stores a random height at each lattice point and interpolates. The blend uses a fade curve whose slope is zero at both ends, so the noise goes flat at every lattice point: every peak, valley and shelf sits on the grid, and the eye sees blocks.
- Perlin noise (Ken Perlin, 1985) stores a random gradient, a slope direction, at each lattice point. The value at a point blends each corner’s gradient dotted with the offset from that corner, so it is exactly zero at every lattice point and the bumps fall between them. His “Improving Noise” (SIGGRAPH 2002) changed the fade from 3t² − 2t³ to 6t⁵ − 15t⁴ + 10t³, whose second derivative is also zero at the ends (no creases where cells meet), and picked gradients from a small fixed set instead of random directions, which clumped. In 2D the lab uses eight: the axes and the diagonals.
- Simplex noise (Perlin, 2001) uses triangles: three corners per sample in 2D instead of four, n + 1 in n dimensions instead of 2ⁿ. Each corner’s contribution fades out radially as (0.5 − d²)⁴, so there is no grid-aligned blend at all. Stefan Gustavson’s “Simplex noise demystified” is the readable reference.
float fade(float t) { return t * t * t * (t * (t * 6 - 15) + 10); } // 6t⁵ − 15t⁴ + 10t³
float perlin(const Lattice& L, float x, float y) {
int ix = floor(x), iy = floor(y);
float fx = x - ix, fy = y - iy; // offset inside the cell
float a = L.grad(ix, iy ).dot({fx, fy });
float b = L.grad(ix + 1, iy ).dot({fx - 1, fy });
float c = L.grad(ix, iy + 1).dot({fx, fy - 1});
float d = L.grad(ix + 1, iy + 1).dot({fx - 1, fy - 1});
return lerp(lerp(a, b, fade(fx)), lerp(c, d, fade(fx)), fade(fy));
}
Loading…
One octave at 4 cycles across, and one row of it as a graph. It starts on value noise along a lattice line: the curve levels off at every dotted line. Switch to Perlin: on a lattice line it crosses zero at each one. Simplex is neither, which is why it shows fewer grid-aligned artefacts.
Octaves: detail at every scale
One octave has features of one size: smooth blobs. Real terrain has detail at every scale, so the standard recipe, fractional Brownian motion (fBm), adds octaves. Each one multiplies the frequency by the lacunarity (usually 2) and the amplitude by the gain, or persistence (usually 0.5): twice as fine, half as strong.
float fbm(const Lattice& L, float x, float y, int octaves, float lacunarity = 2, float gain = 0.5f) {
float sum = 0, amp = 1, freq = 1, total = 0;
for (int k = 0; k < octaves; ++k) {
sum += amp * perlin(L, x * freq + offset[k].x, y * freq + offset[k].y);
total += amp;
freq *= lacunarity; // multiply, don't pow(): same bits everywhere
amp *= gain;
}
return sum / total; // back into about −1..1
}
The per-octave offset matters: without it every octave has a lattice point at the origin, and they all line up there.
Loading…
The lab’s Rolling hills: six octaves of Perlin noise, 3 cycles across the map at the first, lacunarity 2 and gain 0.5. The amplitudes add to 1.97, and the sixth octave adds only 1/32 of the first. Drag the octaves down to 1 and back: the first octave sets the coastline, the rest only roughen it. A lower gain gives smoother land, a higher one rough, noisy land.
Octaves finer than the map can show are wasted, or worse. The sixth octave has 96 cycles across, already only 2.7 samples per cycle on this 256-sample map; an eighth would have 384, under one sample per cycle, and just aliases into grain (the readout turns red). Stop adding octaves when the finest gets below about two samples per cycle. In a game that limit comes from the vertex spacing of your terrain mesh, not the screen.
From a sum of noise to land
A height map is only numbers; a map needs decisions, and shaping functions matter as much as the noise.
- Bands. Heights below sea level are water; the rest is cut into bands (sand, grass, forest, rock, snow). Real games then layer more noise for temperature and rainfall and pick biomes from those.
- Folds. Fold each octave before adding it and the character changes. Ridged noise uses (1 − |n|)²: the zero crossings become sharp crests that read as mountain ranges (F. Kenton Musgrave’s ridged multifractal adds a weighting so ridges get rougher higher up). Billow uses |n|: rounded hills with creases between them.
- Domain warping (Inigo Quilez’s “warp” article) changes where you sample, not what you add: look up
fbm(p + k · (fbm(p + a), fbm(p + b))). Coastlines swirl and stretch as if the land had flowed, and the map stops looking like a sum of blobs. It costs two more fBm evaluations per sample. - Masks. fBm land runs off every edge. To get an island, pull the heights down towards the edges with a function of the distance from the centre.
Loading…
The lab’s Islands preset: six octaves of simplex fBm with the island mask. On seed 1 it leaves 17% of the map above sea level; untick the mask and the same noise gives 58%, running off every side. Then try the folds and the warp.
Open the warped coast in the procgen lab →
Overhangs need a third dimension
A height map has one value per column, so it can’t make overhangs, arches or caves. The earliest Minecraft used a 2D Perlin height map; as Notch described in his post “Terrain generation, Part 1”, he found it dull, “there’s no way for this method to generate any overhangs”, and switched to 3D noise treated as a density: a block is ground where the density is at least zero and air where it is below, with the height added to the noise so there is ground below and air above.
bool solid(int x, int y, int z) { // y up
float density = fbm3(L, x * s, y * s, z * s, 6); // about −1..1
density += (sea_level - y) * falloff; // lower down: more likely ground
return density >= 0;
}
The 2D height map in this lesson is the same idea with one value per column.
Takeaways
- A generated world is a function of its seed: your own PRNG, one stream per system and chunk, and arithmetic that gives the same bits everywhere.
- Value noise is flat at every lattice point and looks blocky; Perlin noise is zero at every lattice point; simplex noise uses triangles and radial falloff, with fewer grid artefacts.
- fBm adds octaves, each finer (lacunarity) and fainter (gain). Stop when the finest drops below about two samples per cycle.
- Noise becomes terrain through shaping: bands, ridged and billow folds, domain warping, masks. For overhangs and caves, use 3D noise as a density.
In the lab
The lab’s noise mode has every knob from this lesson: basis, frequency, octaves, lacunarity, gain, folds, warp, sea level and the island mask, with the octaves stepped one at a time and any row of the map graphed.
Step through the octaves in the procgen lab →
Further reading
- Ken Perlin, Improving Noise (SIGGRAPH 2002): the quintic fade and the fixed gradient set.
- Stefan Gustavson, Simplex noise demystified (2005): simplex noise explained step by step, with code.
- Inigo Quilez, Domain warping: warping fBm with fBm, with the formulas and pictures.
- K.jpg, OpenSimplex2: simplex-style noise in several languages, mostly under CC0.
- Notch, Terrain generation, Part 1 (2011, a copy of his blog post): from a 2D height map to 3D density in early Minecraft.