technique

Noise fields

A smooth random function of position, built from a lattice of seeded values and interpolation, then sliced by thresholds into water, sand, grass, and rock.

Before this

This page assumes you are comfortable with:

Why you need this

The cheapest way to get a believable coastline, a hill, or a patch of forest is not to place tiles at all but to draw a smooth random landscape and cut it into bands. A noise field is that landscape: a function that takes a position and returns a number, random enough that no two places look alike, smooth enough that neighbors agree. It runs in the layout stage to paint biomes across chunks and in the fill stage as a height layer, and it needs no example map, no rules, and no iteration.

The idea

A noise field is a function f(x,y)→[0,1]f(x, y) \to [0, 1] with two properties: it is deterministic given a seed, and it is smooth, meaning f(x,y)f(x, y) and f(x+0.01,y)f(x + 0.01, y) are nearly equal. Pure random numbers per cell have the first property and not the second; that is static, not terrain.

Value noise, step by step.

  1. A lattice of random values. At every integer point (i,j)(i, j) store a value v(i,j)∼Uniform(0,1)v(i, j) \sim \mathrm{Uniform}(0, 1) from a seeded generator. You can fill an array, or better, compute v(i,j)v(i, j) as a hash of (i,j,seed)(i, j, \text{seed}) so that any lattice point can be evaluated on demand without storing a grid. A hash is just a fixed scramble of integer bits that looks random; the code below shows one.
  2. Find the cell. For a query point (x,y)(x, y), let i=⌊x⌋i = \lfloor x \rfloor, j=⌊y⌋j = \lfloor y \rfloor (round down), and the fractional position inside the cell fx=x−if_x = x - i, fy=y−jf_y = y - j, both in [0,1)[0, 1).
  3. Smooth the fraction. Pass each fraction through the smoothstep curve s(t)=3t2−2t3s(t) = 3t^2 - 2t^3. It maps 0 to 0 and 1 to 1 but flattens near both ends, so the slope of ff is zero as you cross a lattice line and the grid does not show. This is the step that Interpolation and smoothing is about.
  4. Bilinear interpolation. Blend the two top corners by s(fx)s(f_x), the two bottom corners by s(fx)s(f_x), then blend those two results by s(fy)s(f_y). With lerp(a,b,t)=a+(b−a)t\mathrm{lerp}(a, b, t) = a + (b - a) t:

f(x,y)=lerp(lerp(v00,v10,s(fx)), lerp(v01,v11,s(fx)), s(fy))f(x, y) = \mathrm{lerp}\big(\mathrm{lerp}(v_{00}, v_{10}, s(f_x)),\ \mathrm{lerp}(v_{01}, v_{11}, s(f_x)),\ s(f_y)\big)

where v00=v(i,j)v_{00} = v(i, j), v10=v(i+1,j)v_{10} = v(i+1, j), v01=v(i,j+1)v_{01} = v(i, j+1), v11=v(i+1,j+1)v_{11} = v(i+1, j+1).

Gradient noise. Perlin's 1985 method stores a random direction (a gradient vector) at each lattice point instead of a value. For a query point, it takes the dot product of each corner's gradient with the offset from that corner to the point, and interpolates those four dot products the same way. Because the value at every lattice point is exactly zero and the variation comes from slopes, the result has no flat plateaus at lattice points and far fewer blocky, axis-aligned artifacts than value noise. Simplex noise is a later variant of gradient noise on a triangular lattice that is cheaper in higher dimensions and has fewer directional artifacts.

Octaves, or fractional Brownian motion. One layer of noise has features of one size. Terrain has mountains with hills on them with rocks on those. So sum several layers:

F(x,y)=∑o=0n−1a o f(ℓ ox,ℓ oy)∑o=0n−1a oF(x, y) = \frac{\sum_{o=0}^{n-1} a^{\,o} \, f(\ell^{\,o} x, \ell^{\,o} y)}{\sum_{o=0}^{n-1} a^{\,o}}

Each layer is an octave. The lacunarity ℓ\ell multiplies the frequency each octave (2 is standard, so features halve in size), and the persistence aa multiplies the amplitude each octave (0.5 is standard, so each finer layer contributes half as much). The denominator keeps the result in [0,1][0, 1]. With four octaves, ℓ=2\ell = 2, a=0.5a = 0.5:

Octave Frequency Amplitude What it adds
0 1 1 continents and seas
1 2 0.5 hills and bays
2 4 0.25 bumps, small islands
3 8 0.125 surface grain

Raise persistence toward 0.8 and the map gets rough and jagged everywhere. Lower it toward 0.3 and it becomes rolling and soft. Raise lacunarity above 2 and the detail octaves look like unrelated speckle on top of the big shapes. Each octave should use a different seed (for example the base seed plus the octave index), or the layers line up and reinforce the lattice.

Worked example

Lattice values around the cell with corners at (0,0)(0, 0), (1,0)(1, 0), (0,1)(0, 1), (1,1)(1, 1):

Corner Value
v00v_{00} 0.2
v10v_{10} 0.9
v01v_{01} 0.6
v11v_{11} 0.4

Query point (0.25,0.75)(0.25, 0.75), so fx=0.25f_x = 0.25, fy=0.75f_y = 0.75.

Smoothstep: s(0.25)=3(0.0625)−2(0.015625)=0.1875−0.03125=0.15625s(0.25) = 3(0.0625) - 2(0.015625) = 0.1875 - 0.03125 = 0.15625 and s(0.75)=3(0.5625)−2(0.421875)=1.6875−0.84375=0.84375s(0.75) = 3(0.5625) - 2(0.421875) = 1.6875 - 0.84375 = 0.84375. Notice how 0.25 was pulled toward 0 and 0.75 toward 1.

Top edge: lerp(0.2,0.9,0.15625)=0.2+0.7×0.15625=0.309\mathrm{lerp}(0.2, 0.9, 0.15625) = 0.2 + 0.7 \times 0.15625 = 0.309. Bottom edge: lerp(0.6,0.4,0.15625)=0.6−0.2×0.15625=0.569\mathrm{lerp}(0.6, 0.4, 0.15625) = 0.6 - 0.2 \times 0.15625 = 0.569. Final: lerp(0.309,0.569,0.84375)=0.309+0.260×0.84375=0.528\mathrm{lerp}(0.309, 0.569, 0.84375) = 0.309 + 0.260 \times 0.84375 = 0.528.

So f(0.25,0.75)≈0.53f(0.25, 0.75) \approx 0.53. Move the query to (0.26,0.75)(0.26, 0.75) and the answer changes by less than 0.01; that is the smoothness.

Thresholding into terrain. Pick cut points t1<t2<t3t_1 < t_2 < t_3 and assign a tile by band:

Band Condition Tile
water F<t1=0.35F < t_1 = 0.35 deep or shallow water
sand t1≤F<t2=0.45t_1 \leq F < t_2 = 0.45 beach
grass t2≤F<t3=0.70t_2 \leq F < t_3 = 0.70 grass, forest floor
rock F≥t3F \geq t_3 cliff, mountain

A row of eight cells from a four-octave field, thresholded:

xx 0 1 2 3 4 5 6 7
FF 0.21 0.30 0.38 0.47 0.61 0.72 0.66 0.52
Tile water water sand grass grass rock grass grass

Moving t1t_1 up floods the map; moving t3t_3 down grows the mountains. Try it below: the sliders change the octaves and the thresholds live, and the three views show the raw field, land versus water, and the four bands.

Combining fields

One field gives elevation. A second field with a different seed gives moisture. Look up the biome in a small two-dimensional table indexed by height band and moisture band. Ecologists call the real-world version a Whittaker diagram, where temperature and rainfall predict the biome; the map generator's version is just a lookup table:

dry medium wet
low desert grassland swamp
mid scrub forest rainforest
high bare rock tundra snow

Domain warping. Before sampling the height field at (x,y)(x, y), sample two more noise fields and use them as offsets: F(x+k g(x,y), y+k h(x,y))F(x + k \, g(x, y),\ y + k \, h(x, y)) with a strength kk around 0.5 to 2 in lattice units. Straight-ish contour lines become swirled and folded, which reads as erosion and rivers rather than as blobs.

Radial falloff for islands. Multiply the field by a function that is 1 at the map center and falls to 0 at the edges, such as 1−(d/dmax⁡)21 - (d / d_{\max})^2 where dd is the distance from the center and dmax⁡d_{\max} is half the map width. Everything near the border drops below t1t_1 and becomes sea, and the map is guaranteed to be an island with no coastline cut off by the edge.

Value noise in JavaScript

About thirty lines. The hash replaces the stored lattice: any integer point, any time, same answer for the same seed.

function hash2(ix, iy, seed) {
  let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1442695041)) | 0;
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;     // in [0, 1)
}
const smooth = (t) => t * t * (3 - 2 * t);
const lerp = (a, b, t) => a + (b - a) * t;

function valueNoise(x, y, seed) {
  const ix = Math.floor(x), iy = Math.floor(y);
  const fx = smooth(x - ix), fy = smooth(y - iy);
  const v00 = hash2(ix, iy, seed),     v10 = hash2(ix + 1, iy, seed);
  const v01 = hash2(ix, iy + 1, seed), v11 = hash2(ix + 1, iy + 1, seed);
  return lerp(lerp(v00, v10, fx), lerp(v01, v11, fx), fy);
}

function fbm(x, y, seed, octaves = 4, lacunarity = 2, persistence = 0.5) {
  let sum = 0, norm = 0, amp = 1, freq = 1;
  for (let o = 0; o < octaves; o++) {
    sum += amp * valueNoise(x * freq, y * freq, seed + o * 1013);
    norm += amp; amp *= persistence; freq *= lacunarity;
  }
  return sum / norm;
}

function terrain(F, t1 = 0.35, t2 = 0.45, t3 = 0.7) {
  return F < t1 ? "water" : F < t2 ? "sand" : F < t3 ? "grass" : "rock";
}
// terrain(fbm(3.2 / 8, 5.7 / 8, 42))  -> one of the four bands

Divide tile coordinates by a scale (8 above) before calling, so one lattice cell spans several tiles; otherwise every tile is its own random value and there is nothing smooth to see.

In a map generator

  • Layout: coarse biome layout across chunks. Evaluate the height and moisture fields once per chunk, at the chunk's center, and read the biome table. Because the field is a function of position, chunk (3,7)(3, 7) gets the same biome whether or not chunk (2,7)(2, 7) has been generated yet, which is what Layered generation needs for streaming.
  • Fill: a height layer for the top-down tilesets. Evaluate per tile inside a chunk and threshold into the biome's ground variants, then let autotiling draw the shorelines. Or feed the height in as a per-tile weight for Wave Function Collapse, so rock tiles are likelier where the field is high.
  • Fill: parallax layer selection for side-scroller backdrops. A one-dimensional slice F(x,0)F(x, 0) along the level gives a height per column; band it into "plains", "hills", "mountains" and pick the far-background layer of the side-scroller pack from that band, so the backdrop changes slowly and never flickers column to column.
  • Regime over time. Add time as a third input, F(x,y,t)F(x, y, t) with tt advancing slowly, and a weather or flooding regime drifts across the map instead of switching per region.

Common mistakes

  • Same seed for height and moisture. The two fields are identical, so wet always means low, and the biome table collapses to its diagonal. Symptom: no deserts at low altitude, no swamps in the hills. Use different seeds, or offset one field by a large constant.
  • Linear instead of smoothstep interpolation. The lattice shows through as a visible grid of creases in the terrain, especially in the raw view. The slope of a linear blend jumps at every lattice line.
  • Threshold bands too thin. With t1=0.35t_1 = 0.35 and t2=0.37t_2 = 0.37 the sand band is one tile wide or missing, and the autotiler has nowhere to place its shoreline pieces. Keep each band at least a few tiles wide at your scale, or widen it where the field's slope is steep.
  • Scale of one tile per lattice cell. No smoothness, just per-tile static. Divide coordinates by a scale of 8 to 32 tiles.
  • Octaves that share a seed. The fine layers sit exactly on the coarse layer's lattice and reinforce its grid. Offset the seed per octave.
  • Floating point drift across machines. The lattice hash must use integer math (Math.imul, shifts) so that two computers agree on the value at every lattice point. Interpolation in floating point is fine because it only blends values that are already agreed.

Cost

For a W×HW \times H region evaluated at nn octaves, the time is O(W⋅H⋅n)O(W \cdot H \cdot n): each sample does four hashes and three interpolations per octave, all constant work. Memory is O(1)O(1) with a hash-based lattice, or O(WH)O(W H) if you store the field for later lookups. It essentially never hurts; a 256x256 map at six octaves is about 400,000 samples, well under a frame. The cost that does show up is the scale of your query, not the algorithm: sampling at a fine resolution over a huge world for a preview is where you start caching per chunk.

Going further

  • Perlin's 1985 gradient noise, then its 2002 revision with the improved fade curve 6t5−15t4+10t36t^5 - 15t^4 + 10t^3.
  • Simplex noise and OpenSimplex, for the triangular-lattice version.
  • Ridged multifractal noise: take 1−∣f∣1 - |f| per octave to get sharp mountain ridges.
  • Hydraulic erosion on a height field, the step after noise if you want real valleys.
  • Worley (cellular) noise, which gives Voronoi-cell patterns for cracked ground and stone.

Leads to

Back to Dynamic map generation