Tutorial · Procedural Generation · Three.js · WebGL
📅 July 2026 ⏱ ≈ 60 min 🎯 Intermediate

Build a Procedural Island From Scratch in One Hour

Combine everything a good island needs — a noise-based mountain silhouette, a coastline that actually tapers off, eroded valleys and beaches, height-driven biome colours, and gently animated water — into one self-contained Three.js generator you can drop into any project.

1. Base Heightmap with fBm Noise

Start with fractional Brownian motion (fBm): sum several octaves of Perlin/simplex noise, each one higher frequency and lower amplitude than the last. Low octaves shape broad mountains; high octaves add rocky detail on top of them.

function fbm(x, y, octaves = 6, lacunarity = 2.0, gain = 0.5) {
  let amplitude = 1, frequency = 1, sum = 0, norm = 0;
  for (let o = 0; o < octaves; o++) {
    sum += amplitude * perlin2D(x * frequency, y * frequency);
    norm += amplitude;
    amplitude *= gain;       // each octave weaker
    frequency *= lacunarity; // each octave finer
  }
  return sum / norm; // normalised to [-1, 1]
}

function buildHeightmap(size) {
  const map = new Float32Array(size * size);
  for (let y = 0; y < size; y++)
    for (let x = 0; x < size; x++)
      map[y * size + x] = fbm(x / size * 4, y / size * 4);
  return map;
}
Ridged variant: Feed 1 − |noise| instead of raw noise into each octave for sharp mountain ridgelines instead of rolling hills — useful for a volcanic island's central peak.

2. Radial Falloff — Turning Terrain into an Island

Raw fBm noise tiles endlessly in every direction — it never decides to become an island. Multiply it by a radial falloff mask that is 1 at the centre and smoothly drops to 0 at the map edge, so height fades to sea level no matter what the noise underneath says.

function falloff(nx, ny) {
  // nx, ny in [-1, 1], distance from centre
  const d = Math.sqrt(nx*nx + ny*ny);
  const a = 3, b = 2.2; // shape tuning constants
  return Math.pow(d, a) / (Math.pow(d, a) + Math.pow(b - b * d, a));
}

function applyIslandMask(map, size) {
  for (let y = 0; y < size; y++)
    for (let x = 0; x < size; x++) {
      const nx = (x / size) * 2 - 1, ny = (y / size) * 2 - 1;
      const i = y * size + x;
      map[i] = (map[i] + 1) / 2 * (1 - falloff(nx, ny));
    }
  return map; // values now in [0, 1], centre high, edges 0
}
Square vs. circular islands: This falloff produces a rounded, natural-looking coastline. For an archipelago, generate the mask at low resolution with its own noise field instead of a pure radial formula, then multiply.

3. Hydraulic Erosion Pass

A raw masked heightmap still looks uniformly bumpy. Run a batch of simulated droplets over it (see the hydraulic erosion article for the full derivation) — each one erodes steep slopes and deposits sediment where it slows down, carving valleys and building up flat beach shelves near the shore.

function erode(map, size, dropletCount = 50000) {
  for (let i = 0; i < dropletCount; i++) {
    const pos = { x: Math.random() * size, y: Math.random() * size };
    simulateDroplet(map, size, pos); // erode/deposit along its path
  }
  return map;
}
Budget for 60 minutes: On a 256×256 heightmap, 20,000–50,000 droplets converge to a visibly eroded island in under a second on the main thread — no need for a GPU compute pass for a tutorial-scale island.

4. Mesh Generation and Normals

Feed the eroded heightmap into a Three.js PlaneGeometry, displacing each vertex's Z by the corresponding height sample, then recompute normals so lighting responds to the new slopes instead of the flat plane's original normal.

function buildMesh(heightmap, size, worldSize = 100, maxHeight = 18) {
  const geo = new THREE.PlaneGeometry(worldSize, worldSize, size - 1, size - 1);
  const pos = geo.attributes.position;

  for (let i = 0; i < pos.count; i++) {
    const h = heightmap[i] * maxHeight;
    pos.setZ(i, h); // plane is XY, displace along local Z
  }

  geo.rotateX(-Math.PI / 2); // lay flat: Z becomes world Y (up)
  geo.computeVertexNormals();
  return new THREE.Mesh(geo, terrainMaterial);
}

5. Biome Colouring by Height and Slope

Colour every vertex in a fragment shader (or per-vertex colour buffer) based on height — beach near sea level, grass mid-slope, rock higher up, snow at the peak — with a slope override so cliffs read as bare rock regardless of altitude:

// GLSL fragment shader, simplified
varying float vHeight;
varying vec3 vNormal;

void main() {
  float slope = 1.0 - dot(normalize(vNormal), vec3(0.0, 1.0, 0.0));

  vec3 beach = vec3(0.85, 0.77, 0.55);
  vec3 grass = vec3(0.25, 0.5,  0.2);
  vec3 rock  = vec3(0.4,  0.37, 0.33);
  vec3 snow  = vec3(0.95, 0.95, 0.97);

  vec3 color = mix(beach, grass, smoothstep(0.05, 0.15, vHeight));
  color = mix(color, rock, smoothstep(0.45, 0.6, vHeight));
  color = mix(color, snow, smoothstep(0.8, 0.92, vHeight));
  color = mix(color, rock, smoothstep(0.35, 0.6, slope)); // steep = rock

  gl_FragColor = vec4(color, 1.0);
}

6. Shoreline Water and Waves

Add a flat, semi-transparent plane at sea level (height 0). A cheap vertex-shader ripple plus a Fresnel term for foam where the water meets the shallow beach sells the illusion without a full ocean simulation:

const waterMat = new THREE.ShaderMaterial({
  transparent: true,
  uniforms: { uTime: { value: 0 } },
  vertexShader: `
    uniform float uTime;
    varying vec3 vPos;
    void main() {
      vec3 p = position;
      p.z += sin(p.x * 0.3 + uTime) * 0.06 + cos(p.y * 0.25 + uTime * 1.3) * 0.05;
      vPos = p;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
    }`,
  fragmentShader: `
    varying vec3 vPos;
    void main() {
      vec3 shallow = vec3(0.25, 0.65, 0.7);
      vec3 deep    = vec3(0.03, 0.15, 0.3);
      gl_FragColor = vec4(mix(shallow, deep, 0.6), 0.85);
    }`
});
Foam line: Render the terrain's height into a render target once, then in the water shader sample the terrain height at the same XY and brighten pixels where terrainHeight is within a small threshold of sea level — an easy way to fake a foam band without extra geometry.

7. Putting It Together

Wrap every step into one seeded, reusable generator so the same seed always reproduces the same island — essential for sharing a seed or saving/loading a scene:

function generateIsland(seed, size = 256) {
  seedNoise(seed);                       // deterministic Perlin permutation table
  let map = buildHeightmap(size);       // 1. fBm
  map = applyIslandMask(map, size);      // 2. radial falloff
  map = erode(map, size);               // 3. hydraulic erosion
  const terrain = buildMesh(map, size); // 4. mesh + normals
  terrain.material = biomeMaterial;      // 5. biome shader
  const water = buildWaterPlane(size, waterMat); // 6. shoreline
  return { terrain, water };
}

const { terrain, water } = generateIsland(1337);
scene.add(terrain, water);
Where to go next: Add trees with instanced meshes scattered on grass-biome vertices, a beach-sand particle shimmer, or hook the seed to a URL parameter so islands are shareable links. Pairs well with the hydraulic erosion and Perlin noise articles for the underlying maths.

Frequently Asked Questions

What will I learn in this tutorial?

Build a complete procedural island in Three.js in one hour: Perlin-noise heightmap, radial falloff mask, hydraulic erosion pass, biome colour grading, and animated shoreline waves.

What topics are covered in this tutorial?

This tutorial covers: Base Heightmap with fBm Noise, Radial Falloff — Terrain Into an Island, Hydraulic Erosion Pass, Mesh Generation and Normals, Biome Colouring by Height and Slope, Shoreline Water and Waves, Putting It Together.

How long does this tutorial take?

This tutorial takes approximately 60 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.