Learn Creative Coding (#77) - Mini-Project: Procedural 3D World

avatar

Learn Creative Coding (#77) - Mini-Project: Procedural 3D World

cc-banner

We've spent sixteen episodes building up a 3D toolkit. Geometry, materials, lighting, shadows, post-processing, instancing, text, audio, physics, raw WebGL, environments, interaction. Each episode focused on one thing. That's how you learn -- isolate the skill, practice it, move on. But creative coding isn't about isolated skills. It's about throwing everything into a pot and seeing what comes out.

This is that pot. We're building a complete procedural 3D world from a single seed number. Terrain from multi-octave noise, height-based biome coloring, a water plane with animated waves, instanced trees and grass, atmospheric dust particles, a full day/night cycle with matching fog and sky, post-processing for bloom and mood, and first-person camera controls so you can walk through the thing. Change the seed, get a completely different world. Same code, infinite landscapes.

It pulls from practically every episode in this arc. Procedural geometry from ep063, instancing from ep070, lighting and shadows from ep068, post-processing from ep069, environments from ep075, and interaction from ep076. If you've been following along, nothing here is new in isolation. What's new is combining it all into one cohesive piece. That's the hard part -- making ten different systems work together without fighting each other. When the fog color matches the sky and the shadows follow the sun and the grass only grows above the water line and the particles catch the light just right, that's when a collection of techniques becomes a world.

The seed: one number to rule them all

The entire world generates from a single integer. Same seed, same terrain, same tree placement, same everything. Different seed, different planet. This makes the world sharable -- paste a seed in the URL and someone else sees exactly what you see.

We need a seeded random number generator. Math.random() doesn't accept a seed, so we write our own. This is a simple mulberry32 PRNG -- fast, deterministic, good enough for procedural generation (not for cyptography, but we're making art, not passwords):

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

Every call returns a number between 0 and 1, same as Math.random(), but the sequence is always identical for the same starting seed. We'll pass this RNG function to every system that needs randomness -- terrain noise offsets, tree placement, grass distribution, particle initial positions.

We also need seeded noise. The terrain uses layered sine functions with offsets derived from the seed:

function createNoiseFunction(rng) {
  // random phase offsets so each seed produces different terrain
  const offsets = [];
  for (let i = 0; i < 8; i++) {
    offsets.push(rng() * 100 - 50);
  }

  return function (x, z) {
    let h = 0;
    h += Math.sin(x * 0.02 + offsets[0]) * Math.cos(z * 0.018 + offsets[1]) * 6.0;
    h += Math.sin(x * 0.055 + offsets[2]) * Math.cos(z * 0.045 + offsets[3]) * 3.0;
    h += Math.sin(x * 0.13 + offsets[4]) * Math.cos(z * 0.11 + offsets[5]) * 1.2;
    h += Math.sin(x * 0.3 + offsets[6]) * Math.cos(z * 0.25 + offsets[7]) * 0.5;
    return h;
  };
}

Four octaves of sine waves, same as the terrain from ep075, but now the phase offsets come from the seed. The first octave gives big rolling hills. Each subsequent octave adds smaller, faster detail. The result is a smooth heightfield that looks different every time you change the seed.

Scene setup and renderer

Standard Three.js setup, but we enable everything we need upfront -- shadows, tone mapping, fog:

import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { Sky } from 'three/addons/objects/Sky.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

// seed from URL or default
const urlSeed = new URLSearchParams(window.location.search).get('seed');
const SEED = urlSeed ? parseInt(urlSeed, 10) : 42;
const rng = createRNG(SEED);
const terrainNoise = createNoiseFunction(rng);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  70, window.innerWidth / window.innerHeight, 0.1, 500
);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

// fog -- color updates with day/night cycle
scene.fog = new THREE.FogExp2(0x88aacc, 0.007);

Math.min(devicePixelRatio, 2) caps the pixel ratio for high-DPI screens. On a 3x retina display, rendering at full resolution means 9x the pixel count of a standard display. For a scene this complex, that's a framerate killer. Capping at 2 looks great and keeps performance reasonable.

Terrain generation

The terrain is a displaced plane with height-based vertex coloring. Same approach as ep075, but we use our seeded noise function:

function generateTerrain(noiseFn) {
  const size = 250;
  const segments = 180;
  const geo = new THREE.PlaneGeometry(size, size, segments, segments);
  geo.rotateX(-Math.PI / 2);

  const pos = geo.attributes.position;
  const colors = new Float32Array(pos.count * 3);
  const col = new THREE.Color();

  for (let i = 0; i < pos.count; i++) {
    const x = pos.getX(i);
    const z = pos.getZ(i);
    const h = noiseFn(x, z);
    pos.setY(i, h);

    // biome coloring by height
    const n = (h + 8) / 16;  // normalize roughly to 0-1
    if (n < 0.3) {
      col.setHSL(0.28, 0.45, 0.18);    // dark green lowlands
    } else if (n < 0.5) {
      col.setHSL(0.26, 0.38, 0.28);    // lighter green slopes
    } else if (n < 0.7) {
      col.setHSL(0.1, 0.3, 0.35);      // brown higher ground
    } else if (n < 0.85) {
      col.setHSL(0.05, 0.15, 0.5);     // rocky gray
    } else {
      col.setHSL(0.0, 0.0, 0.75);      // snowy peaks
    }

    colors[i * 3] = col.r;
    colors[i * 3 + 1] = col.g;
    colors[i * 3 + 2] = col.b;
  }

  geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
  geo.computeVertexNormals();

  const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
    vertexColors: true,
    roughness: 0.88,
    metalness: 0.0
  }));
  mesh.receiveShadow = true;
  mesh.castShadow = true;
  return mesh;
}

const terrain = generateTerrain(terrainNoise);
scene.add(terrain);

Five biome bands: dark green lowlands, lighter green slopes, brown uplands, rocky gray, and white peaks. The transitions are abrupt at this resolution but from a distance with fog they read as natural biome zones. For smoother transitions you'd interpolate between band colors based on exact height, but honestly? The hard transitions look fine once grass and trees cover the lower elevations.

computeVertexNormals() recalculates normals after displacement so lighting follows the actual surface curvature. We covered why this matters in ep066 -- without it the terrain would light as flat even though the vertices are displaced.

Water plane

Everything below a certain height is water. A semi-transparent plane with slight metallic sheen that catches the sky:

const WATER_LEVEL = -1.5;

const waterGeo = new THREE.PlaneGeometry(250, 250, 1, 1);
const waterMat = new THREE.MeshStandardMaterial({
  color: 0x1a5070,
  roughness: 0.08,
  metalness: 0.65,
  transparent: true,
  opacity: 0.72
});
const water = new THREE.Mesh(waterGeo, waterMat);
water.rotation.x = -Math.PI / 2;
water.position.y = WATER_LEVEL;
water.receiveShadow = true;
scene.add(water);

Low roughness + moderate metalness makes it mirror-like. The transparency lets you see terrain below the waterline, giving a sense of depth. In ep075 we used Three.js's Water addon for animated reflections -- that's more realistic but costs a full extra render pass (mirror camera). For this project the simple material works well enough, especially with the bloom pass we're adding later that makes the water surface glow when it catches sunlight.

Instanced trees

Trees are simple geometry -- a cone (canopy) on a cylinder (trunk). What makes them look like a forest is quantity and placement. We use instancing (ep070) to render thousands from a single draw call, and Poisson disk sampling for natural spacing:

function placeTrees(noiseFn, rng, count) {
  // simple canopy: cone
  const canopyGeo = new THREE.ConeGeometry(1.2, 3.0, 6);
  canopyGeo.translate(0, 3.5, 0);
  const trunkGeo = new THREE.CylinderGeometry(0.15, 0.2, 2.0, 5);
  trunkGeo.translate(0, 1.0, 0);

  // merge into single geometry
  const mergedGeo = mergeGeometries(canopyGeo, trunkGeo);

  const treeMat = new THREE.MeshStandardMaterial({
    vertexColors: true,
    roughness: 0.75
  });

  const trees = new THREE.InstancedMesh(mergedGeo, treeMat, count);
  const dummy = new THREE.Object3D();
  let placed = 0;
  let attempts = 0;
  const positions = [];

  while (placed < count && attempts < count * 10) {
    attempts++;
    const x = (rng() - 0.5) * 200;
    const z = (rng() - 0.5) * 200;
    const h = noiseFn(x, z);

    // only place trees above water, below treeline
    if (h < WATER_LEVEL + 0.5 || h > 5.0) continue;

    // minimum distance from other trees (poor man's Poisson)
    let tooClose = false;
    for (const p of positions) {
      const dx = x - p.x;
      const dz = z - p.z;
      if (dx * dx + dz * dz < 9) {  // 3 unit minimum spacing
        tooClose = true;
        break;
      }
    }
    if (tooClose) continue;

    positions.push({ x, z });

    dummy.position.set(x, h, z);
    dummy.rotation.y = rng() * Math.PI * 2;
    const s = 0.6 + rng() * 0.8;
    dummy.scale.set(s, s + rng() * 0.3, s);
    dummy.updateMatrix();
    trees.setMatrixAt(placed, dummy.matrix);

    // green canopy with variation
    const treeCol = new THREE.Color().setHSL(
      0.25 + (rng() - 0.5) * 0.08,
      0.35 + rng() * 0.2,
      0.18 + rng() * 0.12
    );
    trees.setColorAt(placed, treeCol);
    placed++;
  }

  trees.count = placed;
  trees.instanceMatrix.needsUpdate = true;
  trees.instanceColor.needsUpdate = true;
  trees.castShadow = true;
  trees.receiveShadow = true;
  return trees;
}

function mergeGeometries(canopy, trunk) {
  // manually merge two buffer geometries with vertex colors
  const cPos = canopy.attributes.position;
  const tPos = trunk.attributes.position;
  const totalVerts = cPos.count + tPos.count;

  const positions = new Float32Array(totalVerts * 3);
  const normals = new Float32Array(totalVerts * 3);
  const colors = new Float32Array(totalVerts * 3);

  // canopy vertices -- green
  for (let i = 0; i < cPos.count; i++) {
    positions[i * 3] = cPos.getX(i);
    positions[i * 3 + 1] = cPos.getY(i);
    positions[i * 3 + 2] = cPos.getZ(i);
    colors[i * 3] = 0.2;
    colors[i * 3 + 1] = 0.45;
    colors[i * 3 + 2] = 0.15;
  }

  // trunk vertices -- brown
  const offset = cPos.count;
  for (let i = 0; i < tPos.count; i++) {
    positions[(offset + i) * 3] = tPos.getX(i);
    positions[(offset + i) * 3 + 1] = tPos.getY(i);
    positions[(offset + i) * 3 + 2] = tPos.getZ(i);
    colors[(offset + i) * 3] = 0.35;
    colors[(offset + i) * 3 + 1] = 0.22;
    colors[(offset + i) * 3 + 2] = 0.1;
  }

  const merged = new THREE.BufferGeometry();
  merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  merged.setAttribute('color', new THREE.BufferAttribute(colors, 3));
  merged.computeVertexNormals();

  // combine index buffers
  const cIdx = canopy.index;
  const tIdx = trunk.index;
  const indices = new Uint16Array(cIdx.count + tIdx.count);
  for (let i = 0; i < cIdx.count; i++) indices[i] = cIdx.getX(i);
  for (let i = 0; i < tIdx.count; i++) indices[cIdx.count + i] = tIdx.getX(i) + offset;
  merged.setIndex(new THREE.BufferAttribute(indices, 1));

  return merged;
}

const trees = placeTrees(terrainNoise, rng, 800);
scene.add(trees);

The "poor man's Poisson" rejection sampling isn't true Poisson disk but it works well enough -- reject any candidate that falls within 3 units of an existing tree. For 800 trees over a 200x200 area that's sparse enough that the rejection rate stays low. True Poisson disk (Bridson's algorithm) is better for dense packing but this gets the job done.

Trees only grow between water level and the treeline (height 5.0). Below the water they'd be submerged. Above the treeline it's rocky/snowy terrain where trees don't grow. This makes the biome coloring and vegetation tell a consistent story -- green lowlands have trees, brown highlands don't, snowy peaks are bare.

Instanced grass

Same principle as trees, but much more of it and much smaller. Grass blades are tiny double-sided planes:

function createGrass(noiseFn, rng) {
  const bladeGeo = new THREE.PlaneGeometry(0.04, 0.3, 1, 3);
  bladeGeo.translate(0, 0.15, 0);

  const bladeMat = new THREE.MeshStandardMaterial({
    color: 0x3a7828,
    roughness: 0.82,
    side: THREE.DoubleSide
  });

  const count = 40000;
  const grass = new THREE.InstancedMesh(bladeGeo, bladeMat, count);
  const dummy = new THREE.Object3D();

  let placed = 0;
  let attempts = 0;

  while (placed < count && attempts < count * 3) {
    attempts++;
    const x = (rng() - 0.5) * 160;
    const z = (rng() - 0.5) * 160;
    const h = noiseFn(x, z);

    if (h < WATER_LEVEL + 0.3) continue;   // no grass underwater
    if (h > 6.0) continue;                  // no grass above alpine line

    dummy.position.set(x, h, z);
    dummy.rotation.y = rng() * Math.PI * 2;
    dummy.scale.set(
      0.7 + rng() * 0.6,
      0.5 + rng() * 1.0,
      1
    );
    dummy.updateMatrix();
    grass.setMatrixAt(placed, dummy.matrix);

    const gc = new THREE.Color().setHSL(
      0.24 + (rng() - 0.5) * 0.06,
      0.3 + rng() * 0.2,
      0.18 + rng() * 0.12
    );
    grass.setColorAt(placed, gc);
    placed++;
  }

  grass.count = placed;
  grass.instanceMatrix.needsUpdate = true;
  grass.instanceColor.needsUpdate = true;
  grass.castShadow = true;
  return grass;
}

const grass = createGrass(terrainNoise, rng);
scene.add(grass);

40,000 blades, all from one draw call. Each blade has slight color variation around green -- hue, saturation, and lightness all shift a bit per instance. Without this variation it looks like AstroTurf. With it, it reads as natural grass that catches light differently across the field.

The alpine line (6.0) is higher than the treeline (5.0). Grass grows a bit higher than trees -- in real mountains you see meadows above the tree line before reaching bare rock. Small detail, but it sells the biome hierarchy.

Procedural sky and lighting

We use Three.js's Sky shader for the atmosphere and a directional light that tracks the sun. The sun position animates through a day/night cycle:

// sky
const sky = new Sky();
sky.scale.setScalar(10000);
scene.add(sky);

const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 2.5;
skyUniforms['rayleigh'].value = 1.3;
skyUniforms['mieCoefficient'].value = 0.005;
skyUniforms['mieDirectionalG'].value = 0.82;

// lights
const ambientLight = new THREE.AmbientLight(0x445566, 0.4);
scene.add(ambientLight);

const sunLight = new THREE.DirectionalLight(0xffeedd, 2.2);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.left = -50;
sunLight.shadow.camera.right = 50;
sunLight.shadow.camera.top = 50;
sunLight.shadow.camera.bottom = -50;
sunLight.shadow.camera.near = 0.5;
sunLight.shadow.camera.far = 150;
scene.add(sunLight);

The shadow camera frustum covers a 100x100 unit area centered on the origin. For a 250-unit terrain that means shadows are only visible in the central area. This is a deliberate tradeoff -- a larger frustum wastes shadow map resolution (shadows get blocky). In a production game you'd use cascaded shadow maps for different distance bands, but for a creative coding project this is more than enough.

Day/night cycle

One function updates everything -- sun position, sky uniforms, light color and intensity, fog color, ambient level. We covered this pattern in ep075 but let me show it again because the coordination between systems is the point of this project:

function updateDayCycle(elapsed) {
  const cycleDuration = 180;  // 3 minutes per full day
  const t = (elapsed % cycleDuration) / cycleDuration;
  const dayPhase = t * Math.PI * 2;

  // sun position
  const elevation = Math.max(3, Math.sin(dayPhase) * 65 + 15);
  const azimuth = t * 360;

  const phi = THREE.MathUtils.degToRad(90 - elevation);
  const theta = THREE.MathUtils.degToRad(azimuth);
  const sunPos = new THREE.Vector3().setFromSphericalCoords(1, phi, theta);

  skyUniforms['sunPosition'].value.copy(sunPos);
  sunLight.position.copy(sunPos).multiplyScalar(80);

  // light intensity follows sun elevation
  const sunFactor = Math.max(0, Math.sin(dayPhase));
  sunLight.intensity = 0.2 + sunFactor * 2.5;

  // warm light at low angles, white at noon
  const warmth = 1.0 - Math.pow(Math.max(0.01, sunFactor), 0.35);
  sunLight.color.setHSL(0.08 * warmth, 0.45 * warmth, 0.5 + sunFactor * 0.5);

  // ambient tracks daytime
  ambientLight.intensity = 0.08 + sunFactor * 0.45;

  // fog color matches sky horizon
  const fogHue = 0.57 - warmth * 0.47;
  const fogLgt = 0.15 + sunFactor * 0.55;
  scene.fog.color.setHSL(fogHue, 0.2, fogLgt);

  return sunFactor;  // used by particles
}

The warmth value peaks when the sun is low (sunrise/sunset) and drops to zero at noon. It drives the light color from warm golden to neutral white. The fog color shifts from cool blue-gray to warm orange at sunset, matching the sky's horizon. This is the single most important coordination -- mismatched fog and sky creates a visible seam between ground and sky that breaks the illusion.

The function returns sunFactor so other systems (like particles) can react to the time of day. Dust motes are more visible when sunlight is strong. Fireflies only appear at twilight. Having one clean number that represents "how much daytime" simplifies everything.

Atmospheric particles

Dust motes drift through the air, catching sunlight. They're simple additive-blended points -- the same technique from ep075 and ep011:

function createDustParticles(rng) {
  const count = 4000;
  const positions = new Float32Array(count * 3);
  const speeds = new Float32Array(count);

  for (let i = 0; i < count; i++) {
    positions[i * 3] = (rng() - 0.5) * 60;
    positions[i * 3 + 1] = rng() * 25 + 1;
    positions[i * 3 + 2] = (rng() - 0.5) * 60;
    speeds[i] = 0.15 + rng() * 0.45;
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));

  const mat = new THREE.PointsMaterial({
    color: 0xeeddbb,
    size: 0.07,
    transparent: true,
    opacity: 0.35,
    sizeAttenuation: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending
  });

  return { mesh: new THREE.Points(geo, mat), speeds, count };
}

const dust = createDustParticles(rng);
scene.add(dust.mesh);

function updateDust(delta, elapsed, sunFactor) {
  const positions = dust.mesh.geometry.attributes.position.array;

  // fade particles with daylight -- brighter during day
  dust.mesh.material.opacity = 0.1 + sunFactor * 0.35;

  for (let i = 0; i < dust.count; i++) {
    const i3 = i * 3;
    const speed = dust.speeds[i];

    positions[i3 + 1] -= speed * delta * 0.3;
    positions[i3] += Math.sin(positions[i3 + 1] * 0.3 + elapsed * 0.15) * delta * 0.12;
    positions[i3 + 2] += Math.cos(positions[i3 + 1] * 0.2 + elapsed * 0.1) * delta * 0.09;

    // wrap around
    if (positions[i3 + 1] < 0) {
      positions[i3 + 1] = 25 + dust.speeds[i] * 3;
      positions[i3] = (Math.random() - 0.5) * 60;
      positions[i3 + 2] = (Math.random() - 0.5) * 60;
    }
  }

  dust.mesh.geometry.attributes.position.needsUpdate = true;
}

The opacity scales with sunFactor -- dust is barely visible at night and brightest at noon. This is physically accurate (dust is visible because it scatters direct sunlight) and also looks right. At sunset the low-angle light makes dust particles glow golden. At night they all but disappear.

I'm using Math.random() for the wrap-around respawn positions rather than the seeded RNG. That's intentional -- the initial positions are seeded for reproducibility, but the runtime wrapping is cosmetic animation that doesn't need to be deterministic. Using the seeded RNG here would advance its state and potentially change other seeded systems if the order of operations ever changed. Keep deterministic and runtime randomness separate.

Post-processing: bloom

Bloom makes bright areas glow, which adds atmosphere. The sun reflecting off water, bright grass in direct sunlight, the sky near the horizon -- all of these bleed light slightly, softening the whole image:

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));

const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth, window.innerHeight),
  0.4,    // strength -- subtle, not a JJ Abrams movie
  0.6,    // radius
  0.85    // threshold -- only bloom bright areas
);
composer.addPass(bloomPass);

The threshold of 0.85 means only pixels brighter than 85% intensity bloom. This keeps the effect subtle -- water and sky glow, dark terrain doesn't. Strength 0.4 is deliberately low. Too much bloom and the scene looks like it's smeared in vaseline. The goal is "atmospheric softness" not "stare into a flashbang."

First-person controls and terrain following

PointerLockControls gives us FPS-style mouselook. We add WASD movement and terrain following -- the camera Y position lerps smoothly to the terrain height at the current XZ position:

const controls = new PointerLockControls(camera, document.body);

// click to lock pointer
document.addEventListener('click', function () {
  controls.lock();
});

const moveSpeed = 12;
const eyeHeight = 2.5;
const keys = {};

document.addEventListener('keydown', function (e) { keys[e.code] = true; });
document.addEventListener('keyup', function (e) { keys[e.code] = false; });

function updateMovement(delta) {
  const speed = moveSpeed * delta;
  const direction = new THREE.Vector3();

  if (keys['KeyW']) direction.z -= 1;
  if (keys['KeyS']) direction.z += 1;
  if (keys['KeyA']) direction.x -= 1;
  if (keys['KeyD']) direction.x += 1;

  if (direction.lengthSq() > 0) {
    direction.normalize();
    controls.moveRight(direction.x * speed);
    controls.moveForward(-direction.z * speed);
  }

  // terrain following: lerp camera Y to terrain height + eye level
  const terrainH = terrainNoise(camera.position.x, camera.position.z);
  const targetY = Math.max(terrainH, WATER_LEVEL) + eyeHeight;
  camera.position.y += (targetY - camera.position.y) * 0.1;
}

The Math.max(terrainH, WATER_LEVEL) prevents the camera from going underwater -- if the terrain dips below water level, the camera hovers at water level + eye height instead of plunging under. You walk across the water surface rather than sinking. If you wanted underwater swimming, you'd remove the Math.max and add an underwater fog/tint effect when the camera Y drops below WATER_LEVEL.

The lerp factor of 0.1 smooths the camera movement over terrain bumps. Without smoothing, walking over uneven ground would feel like riding a mechanical bull. With it, the camera glides up and down gently, following the terrain's contour without jerking. Higher values (0.3+) follow the terrain more tightly. Lower values (0.05) feel floaty, like you're a ghost hovering over the landscape.

The animation loop: tying it all together

This is where every system coordinates. One loop, every system updates, the composer renders:

const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);
  const delta = Math.min(clock.getDelta(), 0.05);  // cap delta for tab-away
  const elapsed = clock.getElapsedTime();

  // day cycle
  const sunFactor = updateDayCycle(elapsed);

  // movement
  updateMovement(delta);

  // particles
  updateDust(delta, elapsed, sunFactor);

  // water wave animation
  water.position.y = WATER_LEVEL + Math.sin(elapsed * 0.8) * 0.08;

  // render through composer for bloom
  composer.render();
}

animate();

The delta cap (Math.min(delta, 0.05)) prevents massive jumps when you tab away and come back. Without it, getDelta() returns the total time the tab was inactive (could be minutes), and every system tries to advance by that much in one frame -- particles teleport, the day cycle skips, the camera lurches. Capping at 50ms (20 FPS equivalent) means the worst that happens is a brief slowdown.

The water bobs gently on a sine wave. It's not realistic wave simulation (that would need a vertex shader modifying the water mesh) but the subtle up-and-down movement combined with the low-roughness material creates a convincing enough water feel, especially with the bloom softening the reflections.

Resize handler

Standard but critical -- without it the scene stretches weirdly if you resize the window:

window.addEventListener('resize', function () {
  const w = window.innerWidth;
  const h = window.innerHeight;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
  composer.setSize(w, h);
  bloomPass.resolution.set(w, h);
});

The composer and bloom pass need to be resized too. If you only resize the renderer, the post-processing still runs at the old resolution and the output gets stretched or cropped. Three.js's EffectComposer doesn't auto-resize -- you have to tell it explicitly.

Creative exercise: make it yours

Allez, the world generates. You can walk through it. The sun moves across the sky, dust catches the light, trees dot the hillsides, water fills the valleys. Now make it yours.

Some ideas. :-)

Pick a different biome entirely. Replace the green/brown/white terrain colors with desert tones (tan, red sandstone, pale sand). Remove trees, add rock formations (randomly placed icosahedrons scaled tall and thin). Change the fog to warm dusty ochre. Swap grass for tumbleweeds (spheres with wireframe material scattered sparsely).

Or go alien. Purple terrain, cyan trees, orange sky. Floating crystal formations above the landscape (boxes rotated 45 degrees on all axes, slowly spinning, positioned at random XZ with Y above the terrain). Bioluminescent grass that pulses with a sine wave on the emissive channel. Two suns.

Or go minimal. Remove trees, grass, particles. Just terrain, water, sky, fog. One material color for the whole terrain -- dark charcoal. Dense fog, nearly monochrome. A single directional light at a low angle casting long shadows across the rolling hills. Post-processing with high bloom and a color grading pass that desaturates everything. Stark, atmospheric, quiet.

Generate 10 worlds from seeds 0-9. Pick the one with the most interesting terrain -- a seed that produces a big valley with water surrounded by hills, or a dramatic ridge line, or a plateau overlooking lowlands. Screenshot it at golden hour (scrub the day/night cycle to sunset elevation). That screenshot is a portfolio piece. Put the seed in the URL so anyone can visit the exact same vista.

Pulling it all together

This mini-project doesn't introduce new techniques. Everything here exists in previous episodes. What it teaches is integration -- making disparate systems cooperate. The fog has to match the sky. The vegetation has to respect the terrain height. The lighting has to drive the mood. The post-processing has to enhance, not overpower. The camera has to follow the terrain smoothly. The seed has to flow through every random decision consistently.

That coordination is the actual skill of creative coding at this scale. Each peice is simple. The complexity is in the interfaces between pieces. When the fog color drifts away from the sky color, the illusion breaks. When grass spawns underwater, the world looks buggy. When bloom is too strong, the scene looks like a smartphone filter. These aren't code bugs -- they're aesthetic bugs. You fix them by adjusting numbers, watching the result, adjusting again. The feedback loop between changing a parameter and seeing its effect in context is the process.

Every time you create a new world from a seed, you're testing whether your systems are robust. Does seed 0 look good? Does seed 99? Does seed 1000? If one seed produces a world that looks broken -- all water, or all peaks, or trees in the water -- that tells you which constraints need tightening. The seed is your test harness.

What's ahead

We've built a procedural world that you can walk through. The 3D arc covered geometry, materials, lighting, post-processing, instancing, physics, environments, interaction, and now integration. The toolkit is complete -- everything from making a mesh appear in a scene to building a full environment with atmosphere and first-person navigation.

Next up we're stepping outside the browser window entirely. WebXR takes the 3D scenes we've been building and puts them into VR headsets and AR overlays. Same Three.js, same renderer, different output target. The transition is surprisingly smooth -- most of what we've built works in VR with minimal changes.

't Komt erop neer...

  • A seeded PRNG (mulberry32) generates deterministic random sequences from a single integer. Same seed = same world. Pass the seed via URL parameter so worlds are sharable. Use the seeded RNG for all placement decisions (terrain, trees, grass) but regular Math.random() for runtime animation (particle respawns)
  • Terrain: displaced PlaneGeometry with four octaves of sine waves. Phase offsets come from the seed so each seed produces different hills. Height-based vertex coloring gives biome bands -- green lowlands, brown slopes, gray rock, white peaks. computeVertexNormals() after displacement for correct lighting
  • Water plane at a fixed Y level with low roughness and moderate metalness for mirror-like reflections. Transparency lets you see submerged terrain. Gentle sine-wave bobbing for subtle animation. Bloom makes the surface glow when it catches sunlight
  • Instanced trees (800) from merged cone+cylinder geometry. Rejection sampling enforces minimum spacing. Trees only grow between water level and treeline. Random scale, rotation, and color variation per instance. One draw call for the entire forest
  • Instanced grass (40,000) from tiny double-sided planes. Same biome constraints as trees but with a higher alpine line. Per-instance color variation prevents the AstroTurf look
  • Procedural sky via Three.js Sky shader driven by sun position. Day/night cycle animates sun position, light intensity/color, ambient level, fog color. All coordinated -- fog matches sky horizon color. The sunFactor value (0 at night, 1 at noon) drives other systems
  • Dust particles (4000) drift slowly with additive blending. Opacity scales with sunFactor -- visible during day, invisible at night. Seeded initial positions, runtime Math.random() for respawns
  • Post-processing: UnrealBloomPass with subtle settings (strength 0.4, threshold 0.85). Only bright areas bloom. The point is atmospheric glow, not lens flare
  • First-person controls (PointerLockControls) with WASD movement and terrain-following camera. Camera Y lerps to terrain height + eye height. Math.max(terrainH, WATER_LEVEL) prevents going underwater. Lerp factor 0.1 smooths bumpy terrain
  • Delta time capped at 0.05 to prevent physics explosions when tabbing away. Resize handler updates renderer, composer, and bloom pass -- EffectComposer doesn't auto-resize
  • The creative exercise: change the biome. Desert, alien, minimal -- swap colors, vegetation, fog, and the same systems produce radically different worlds. Generate many seeds, find interesting terrain, screenshot at golden hour for a portfolio piece

Sallukes! Thanks for reading.

X

@femdev



0
0
0.000
0 comments