Three.js From Zero · Article s2-10

Procedural Worlds — Noise, Terrain, Streaming

← threejs-from-zeroS2 · Article 10 Season 2 finale
Article S2-10 · Three.js From Zero

Procedural Worlds — Noise, Terrain, Streaming

Season 2 finale. We've done physics, characters, VR, multiplayer, and GPGPU. Today we build a world. Not one you authored — one that generates itself as you fly. Noise functions shape the terrain. Chunks load and unload based on camera distance. Instance scattering plants grass and rocks in matching biomes. It's the pattern behind Minecraft, No Man's Sky, every infinite runner.

Fly over the demo to watch chunks pop in around you. Adjust the noise parameters. Change octaves. The world regenerates in real time.

generating world…

Noise is the starting point

A noise function takes a 2D or 3D coordinate and returns a smooth pseudo-random number. Same input = same output (so the world is repeatable). Nearby inputs = similar outputs (so it's smooth). The classic ones: Perlin, Simplex, Worley (cellular), Value noise.

// A single octave of 2D value noise
function hash2(x, y) {
  return fract(sin(x * 12.9898 + y * 78.233) * 43758.5453);
}
function smoothstep(a, b, t) { t = clamp((t-a)/(b-a), 0, 1); return t*t*(3-2*t); }

function noise2(x, y) {
  const xi = Math.floor(x), yi = Math.floor(y);
  const xf = x - xi, yf = y - yi;
  const a = hash2(xi,   yi  );
  const b = hash2(xi+1, yi  );
  const c = hash2(xi,   yi+1);
  const d = hash2(xi+1, yi+1);
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  return (a*(1-u)+b*u)*(1-v) + (c*(1-u)+d*u)*v;
}

FBM — layering octaves

A single noise octave gives you smooth rolling hills. Real terrain has features at many scales — continents, mountains, boulders, pebbles. Fractal Brownian Motion = sum multiple octaves of noise at increasing frequency, decreasing amplitude:

function fbm(x, y, octaves = 4, persistence = 0.5, lacunarity = 2.0) {
  let total = 0, freq = 1, amp = 1, maxVal = 0;
  for (let i = 0; i < octaves; i++) {
    total += noise2(x * freq, y * freq) * amp;
    maxVal += amp;
    amp *= persistence;
    freq *= lacunarity;
  }
  return total / maxVal;   // normalize to 0..1
}

Four knobs worth knowing:

  • Octaves — how many noise layers. More = more detail, more cost.
  • Amplitude — the overall terrain height scale.
  • Frequency — base noise scale. Low = big features, high = tight detail.
  • Persistence — how fast amplitude decays across octaves. 0.5 = standard. Lower = smoother (just big shapes), higher = rougher.
  • Lacunarity — frequency multiplier per octave. 2.0 is standard (doubles each octave).

Heightfield → geometry

Sample noise over a grid, use as Y coordinates on a subdivided plane:

const geom = new THREE.PlaneGeometry(SIZE, SIZE, RES, RES);
geom.rotateX(-Math.PI / 2);

const pos = geom.attributes.position;
for (let i = 0; i < pos.count; i++) {
  const x = pos.getX(i);
  const z = pos.getZ(i);
  const y = fbm(x * 0.04, z * 0.04, 4, 0.5) * 6;
  pos.setY(i, y);
}
pos.needsUpdate = true;
geom.computeVertexNormals();   // crucial — lighting will be wrong otherwise

Chunks — the infinite-world trick

One giant plane is wasteful. Split the world into square chunks (e.g. 32×32 meters each). Only build chunks near the camera. Unload chunks that leave the "view radius".

const CHUNK_SIZE = 32;
const CHUNK_RES = 32;
const LOAD_RADIUS = 4;     // chunks — so 9×9 = 81 loaded around camera

const loaded = new Map();   // key "cx,cz" → mesh

function update(cameraPos) {
  const ccx = Math.floor(cameraPos.x / CHUNK_SIZE);
  const ccz = Math.floor(cameraPos.z / CHUNK_SIZE);

  // Load chunks in range
  for (let dx = -LOAD_RADIUS; dx <= LOAD_RADIUS; dx++) {
    for (let dz = -LOAD_RADIUS; dz <= LOAD_RADIUS; dz++) {
      const cx = ccx + dx, cz = ccz + dz;
      const key = cx + ',' + cz;
      if (!loaded.has(key)) loaded.set(key, makeChunk(cx, cz));
    }
  }

  // Unload chunks outside range
  for (const [key, mesh] of loaded) {
    const [cx, cz] = key.split(',').map(Number);
    if (Math.abs(cx - ccx) > LOAD_RADIUS || Math.abs(cz - ccz) > LOAD_RADIUS) {
      scene.remove(mesh);
      mesh.geometry.dispose();
      mesh.material.dispose();
      loaded.delete(key);
    }
  }
}

update runs every frame (or every N frames — chunks don't need per-frame reload checks). The world feels infinite because only the ~81 chunks around you ever exist at a time.

Chunk seams — the gotcha

Each chunk computes its own vertex normals. Neighboring chunks share an edge but calculate normals independently → visible seams at boundaries, especially under lighting.

Three fixes, rough-to-best:

  1. Overlap + crop — generate each chunk with one extra row of vertices on each side, use for normal calc only, discard the geometry. Seamless.
  2. Shared normal function — compute normals analytically from the noise function (partial derivatives) instead of from the mesh.
  3. One mega-mesh — single geometry with all chunks, LOD'd. The classic approach for static worlds.

LOD by distance

Near chunks need detail. Far chunks don't. A chunk 1km away at 32×32 resolution is wasting triangles — most won't project to more than 1 pixel.

Quadtree LOD: the world is a quadtree where each node is a chunk. As you get closer to a node, it subdivides into 4 higher-res children. As you move away, children collapse back into the parent. Seams between LOD levels need "skirts" — vertical strips of geometry that hide the crack.

// Simple distance-based LOD without quadtree
function resolutionFor(chunkDistance) {
  if (chunkDistance < 2) return 64;   // near — high detail
  if (chunkDistance < 5) return 32;
  if (chunkDistance < 10) return 16;
  return 8;                           // far — coarse
}

Biomes — coloring by elevation

Use the heightmap itself to color the terrain: low = water/sand, middle = grass, high = rock/snow. Do it in the fragment shader, not per-vertex, for crisp boundaries:

fragmentShader: `
  varying float vElevation;
  void main() {
    vec3 col;
    if (vElevation < 0.5)      col = vec3(0.2, 0.5, 0.9);   // water
    else if (vElevation < 1.5) col = vec3(0.9, 0.85, 0.6);  // sand
    else if (vElevation < 5.0) col = vec3(0.3, 0.6, 0.3);   // grass
    else                       col = vec3(0.8, 0.8, 0.82);  // snow
    gl_FragColor = vec4(col, 1.0);
  }
`

Add a second noise ("moisture") for rivers/forests — two axes → biome grid. Same pattern as Minecraft's biome system.

Instance scattering — grass, rocks, trees

Grass every 50cm on a 100×100m terrain is 40,000 blades. Per-frame draw calls: 40,000 meshes would destroy performance. Use InstancedMesh (Article 09 throwback):

const GRASS_PER_CHUNK = 500;
const grassMesh = new THREE.InstancedMesh(grassGeom, grassMat, GRASS_PER_CHUNK);

for (let i = 0; i < GRASS_PER_CHUNK; i++) {
  const lx = Math.random() * CHUNK_SIZE;
  const lz = Math.random() * CHUNK_SIZE;
  const y = sampleTerrainHeight(chunkX + lx, chunkZ + lz);
  const slope = sampleSlope(...);
  if (slope > 0.6 || y < 1) continue;   // no grass on cliffs or in water

  dummy.position.set(lx, y, lz);
  dummy.rotation.y = Math.random() * Math.PI * 2;
  dummy.scale.setScalar(0.5 + Math.random() * 0.5);
  dummy.updateMatrix();
  grassMesh.setMatrixAt(i, dummy.matrix);
}
grassMesh.instanceMatrix.needsUpdate = true;

Per chunk: one grass draw call. Whole world of grass = ~81 draw calls at 4-chunk radius. Add a vertex shader wind sway and you've got a living field.

Roads, rivers, paths

These can't be done with pure noise — they need global structure. Common approach: poisson disc sampling for placing key points (village centers, lakes), A* or flow-field for connecting them (roads), river erosion for water carving realistic valleys into the terrain post-noise.

Deep topic — whole PhDs written on it. For gameplay-ready procedural worlds, start with noise + biomes, layer manual features on top as gameplay logic.

Chunk worker — don't block the main thread

Generating a 64×64 chunk's noise on the main thread takes ~10ms. At 60fps that's most of your frame budget. Move chunk generation to a Web Worker:

// worker.js
self.addEventListener('message', (e) => {
  const { cx, cz, size, res, params } = e.data;
  const heights = new Float32Array((res + 1) * (res + 1));
  // ...fill with fbm samples...
  self.postMessage({ cx, cz, heights }, [heights.buffer]);
});

The [heights.buffer] second argument transfers ownership (zero-copy) — the worker's Float32Array is now owned by main thread, no serialization overhead.

Seeded randomness — reproducible worlds

Every player with seed 42 should see the same world. Plain Math.random won't do it. Use a seeded PRNG:

function mulberry32(seed) {
  return () => {
    let t = seed = (seed + 0x6D2B79F5) | 0;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const rng = mulberry32(42);
// Noise function should also be seeded — feed the seed through hash coefficients

Same seed → same world. Critical for multiplayer consistency, player-shared seeds, and testing.

Common first-time pitfalls

  • Seams between chunks. Overlap-and-crop fixes it most reliably. Or analytic normals.
  • Main thread stalls when loading chunks. Move to Web Worker.
  • Terrain looks like raw noise. Add FBM (multiple octaves). Add biome coloring. Add instance scattering. Noise alone isn't world.
  • World regenerates differently each reload. You're using Math.random in the noise path. Seed it.
  • Grass floats or sinks into terrain. Your instance Y doesn't match the terrain sample. Use the same noise function + same params for both.
  • Lighting on far chunks looks wrong. Low-LOD chunks have coarse normals. Either compute normals at full-res and copy, or lean into the stylized look.

Libraries worth knowing

Exercises

  1. Move generation to a Web Worker: your main thread FPS should stop dipping during chunk loads. Measure before/after.
  2. Add trees: on each chunk, scatter ~20 tree instances in grass biome zones. Use a simple cone + cylinder or load a low-poly glTF.
  3. Quadtree LOD: replace the naive 9×9 load with a quadtree that subdivides only near the camera. Measure triangle savings.

Season 2 complete 🎉

10 articles. Physics, XR, multiplayer, GPGPU, procedural worlds.

You can now build, simulate, render, share, and generate worlds on the web.

← back to the series index

What's next — Season 3

Character & Animation. The hardest thing in real-time 3D: making characters feel alive. Skeletal animation internals, IK solvers, blend trees, facial capture, physics-driven animation. 10 more articles coming.