← Blog
Procedural GenerationCanvas APIMathTypeScript

Generating Natural Terrain with Perlin Noise — Why Randomness Can Be Beautiful

Updated May 27, 20269 min read

Go deeper on this topic

Creative Coding

Mathematical simulations, nature visualization, and generative art in the browser

Article 4 of 12 in this series.

View topic page

Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →

Why Math.random() Doesn't Cut It

If you determine terrain height with Math.random(), adjacent cells end up with unrelated values, producing jagged, meaningless terrain. Perlin noise generates random numbers where adjacent values transition smoothly.

How Gradient Noise Works

In the 2D case, random gradient vectors are assigned to integer lattice points, and the dot products between each point's distance vector and the gradient vectors at the lattice corners are interpolated:

// Gradient vectors (limited to 8 directions)
const GRAD = [
  [1, 1], [-1, 1], [1, -1], [-1, -1],
  [1, 0], [-1, 0], [0, 1], [0, -1],
]

// Hash function (lattice point → gradient index)
const PERM = new Uint8Array(512)
function initPerm() {
  const p = Array.from({ length: 256 }, (_, i) => i)
  // Fisher-Yates shuffle
  for (let i = 255; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[p[i], p[j]] = [p[j], p[i]]
  }
  for (let i = 0; i < 512; i++) PERM[i] = p[i & 255]
}

function fade(t: number): number {
  return t * t * t * (t * (t * 6 - 15) + 10)
}

function lerp(a: number, b: number, t: number): number {
  return a + t * (b - a)
}

The fade function is a quintic smooth S-curve. This is Ken Perlin's improved version (2002), where both the first and second derivatives are zero at the boundaries.

2D Perlin Noise

function perlin2d(x: number, y: number): number {
  const xi = Math.floor(x) & 255
  const yi = Math.floor(y) & 255
  const xf = x - Math.floor(x)
  const yf = y - Math.floor(y)

  const u = fade(xf)
  const v = fade(yf)

  const aa = PERM[PERM[xi] + yi]
  const ab = PERM[PERM[xi] + yi + 1]
  const ba = PERM[PERM[xi + 1] + yi]
  const bb = PERM[PERM[xi + 1] + yi + 1]

  function dot(hash: number, dx: number, dy: number): number {
    const g = GRAD[hash & 7]
    return g[0] * dx + g[1] * dy
  }

  const x1 = lerp(dot(aa, xf, yf), dot(ba, xf - 1, yf), u)
  const x2 = lerp(dot(ab, xf, yf - 1), dot(bb, xf - 1, yf - 1), u)

  return lerp(x1, x2, v)
}

The return value ranges from -1 to 1. This is used directly as a height map.

Octave Composition

A single layer of Perlin noise can only express broad undulations. By layering multiple frequencies, natural terrain detail emerges:

function fbm(
  x: number,
  y: number,
  octaves: number,
  lacunarity = 2.0,
  persistence = 0.5,
): number {
  let value = 0
  let amplitude = 1
  let frequency = 1
  let maxValue = 0

  for (let i = 0; i < octaves; i++) {
    value += perlin2d(x * frequency, y * frequency) * amplitude
    maxValue += amplitude
    amplitude *= persistence
    frequency *= lacunarity
  }

  return value / maxValue
}
  • lacunarity: The rate of frequency increase (2.0 means each octave is twice as detailed)
  • persistence: The rate of amplitude decay (0.5 means each octave has half the influence)

4-6 octaves produce sufficiently realistic terrain.

Rendering a 3D Terrain Mesh

PerlinLandscape renders a pseudo-3D mesh using only the Canvas API's 2D context. It uses isometric projection:

function renderTerrain(
  ctx: CanvasRenderingContext2D,
  heightMap: number[][],
  cols: number,
  rows: number,
) {
  const cellSize = 8
  const heightScale = 80

  ctx.strokeStyle = 'rgba(100, 200, 255, 0.6)'
  ctx.lineWidth = 0.5

  // Draw back to front (Painter's Algorithm)
  for (let y = 0; y < rows - 1; y++) {
    for (let x = 0; x < cols - 1; x++) {
      const h = heightMap[y][x]

      // Isometric transformation
      const sx = (x - cols / 2) * cellSize + ctx.canvas.width / 2
      const sy = (y - rows / 2) * cellSize * 0.5 - h * heightScale + ctx.canvas.height / 2

      const h2 = heightMap[y][x + 1]
      const sx2 = (x + 1 - cols / 2) * cellSize + ctx.canvas.width / 2
      const sy2 = (y - rows / 2) * cellSize * 0.5 - h2 * heightScale + ctx.canvas.height / 2

      ctx.beginPath()
      ctx.moveTo(sx, sy)
      ctx.lineTo(sx2, sy2)
      ctx.stroke()
    }
  }
}

Halving the y-axis scale creates a sense of depth. You can make terrain look convincing with Canvas 2D alone, without WebGL.

Difference from the PerlinNoise Component

PerlinNoise is a simple demo that displays 2D noise as grayscale shading. PerlinLandscape uses the same noise function but adds 3D projection and height mapping. The same mathematical function creates an entirely different experience depending on how you "present" it.

Summary: Technologies and Tools Used for Perlin Noise

The applications of Perlin noise span game development as a whole. The noise() function in p5.js is Perlin noise itself and is ideal for prototyping. The books in the toolshelf cover everything from the fundamentals of procedural generation to applications in level design.