Generating Natural Terrain with Perlin Noise — Why Randomness Can Be Beautiful
Go deeper on this topic
Creative Coding
Mathematical simulations, nature visualization, and generative art in the browser
Article 4 of 12 in this series.
Next reads
Visualizing Wave Equations in the Browser — Interference, Diffraction, and Resonance
Implementing 1D and 2D wave equations with finite difference methods and visualizing interference, diffraction, and resonance patterns on Canvas. Rendering techniques for WaveInterference, WaveEquation, and ChladniPlate.
The N-Body Problem in the Browser — Implementing Gravitational Simulation
Implementation details of gravitational calculations, vector operations, integration methods, and orbit rendering used in the NBody, SolarSystem, and GravityWell components.
Exploring the Reaction-Diffusion Parameter Space — Four Faces of the Gray-Scott Model
Varying feed/kill rates in the Gray-Scott Reaction-Diffusion model produces entirely different patterns: spots, stripes, coral, and cell division. Implementation on a 200x200 grid with exploration notes.
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.