Build a procedural village

Prefabs only go so far. This tutorial writes a house from scratch with MeshBuilder, using boxes for walls, quads for roof slopes and triangles for gables, then uses a seeded random generator to arrange thirty of them into a village at dusk. Press the button for a new one.

Drag to look around, scroll or pinch to zoom, and press New village to grow another from a fresh seed.

The whole program

import { createApp, SceneObject, MeshBuilder, material, boxCollider, createGround, createTree, createLampPost, createRandom } from "wgpu3d";

const app = await createApp({ canvas: document.querySelector("#village-demo"), environment: "dusk" });
app.add(createGround({ size: 160, tiles: 32, color: "#4f6b3c", alternateColor: "#4a6538" }));

function buildHouse(random) {
  const width = random.range(4, 7), depth = random.range(5, 8), wallHeight = random.range(2.6, 4.2);
  const roofHeight = random.range(1.4, 2.6);
  const wall = material(random.pick(["#e8dcc8", "#d9b99b", "#c9d3c6", "#efe6d8", "#c98d6b"]), { gloss: 0.05 });
  const roof = material(random.pick(["#8e3b2f", "#5a4636", "#3f4b5c", "#7a5230"]), { gloss: 0.3 });
  const window = material("#ffcf7a", { glow: random() < 0.75 ? 0.9 : 0 });
  const builder = new MeshBuilder();
  builder.box({ center: [0, wallHeight / 2, 0], size: [width, wallHeight, depth], material: wall });

  const halfWidth = width / 2 + 0.3, halfDepth = depth / 2 + 0.3, ridge = wallHeight + roofHeight;
  builder.quad([-halfWidth, wallHeight, -halfDepth], [0, ridge, -halfDepth], [0, ridge, halfDepth], [-halfWidth, wallHeight, halfDepth], roof);
  builder.quad([halfWidth, wallHeight, -halfDepth], [0, ridge, -halfDepth], [0, ridge, halfDepth], [halfWidth, wallHeight, halfDepth], roof);
  for (const z of [-depth / 2, depth / 2]) {
    builder.triangle([-width / 2, wallHeight, z], [0, ridge - 0.25, z], [width / 2, wallHeight, z], wall);
  }

  builder.box({ center: [0, 0.95, depth / 2 + 0.02], size: [0.9, 1.9, 0.06], material: material("#4a2f1d") });
  for (const x of [-width / 4 - 0.3, width / 4 + 0.3]) {
    builder.box({ center: [x, wallHeight * 0.55, depth / 2 + 0.02], size: [0.8, 0.8, 0.05], material: window });
  }
  builder.box({ center: [width / 4, ridge - 0.4, 0], size: [0.5, 1.4, 0.5], material: material("#6d655d") });
  return new SceneObject({ mesh: builder.toMesh(), collider: { ...boxCollider([width, wallHeight, depth]), offset: [0, wallHeight / 2, 0] } });
}

let village = null;
function buildVillage(seed) {
  village?.removeFromParent();
  village = new SceneObject({ name: "village" });
  const random = createRandom(seed);
  for (let ring = 1; ring <= 3; ring++) {
    const houses = ring * 5;
    for (let index = 0; index < houses; index++) {
      const angle = (index / houses) * Math.PI * 2 + random.range(-0.1, 0.1);
      const distance = ring * 13 + random.range(-2, 2);
      const house = buildHouse(random);
      house.position = [Math.cos(angle) * distance, 0, Math.sin(angle) * distance];
      house.yaw = Math.PI / 2 - angle + Math.PI;
      village.add(house);
    }
  }
  for (let index = 0; index < 8; index++) {
    const angle = (index / 8) * Math.PI * 2;
    village.add(createLampPost({ position: [Math.cos(angle) * 6.5, 0, Math.sin(angle) * 6.5] }));
  }
  for (let index = 0; index < 40; index++) {
    const angle = random() * Math.PI * 2, distance = random.range(48, 75);
    village.add(createTree({ seed: seed * 100 + index, kind: random() < 0.6 ? "pine" : "round", height: random.range(5, 10), position: [Math.cos(angle) * distance, 0, Math.sin(angle) * distance] }));
  }
  app.add(village);
}

buildVillage(1);
document.querySelector("#village-reseed").addEventListener("click", () => buildVillage(Math.floor(Math.random() * 100000)));
app.orbit({ target: [0, 2, 0], distance: 55, pitch: 0.45, autoRotateSpeed: 0.08, maxDistance: 110 });
app.start();

One house, piece by piece

buildHouse returns a complete object, collider and all. Its proportions and colours are drawn from the generator, so no two houses match.

You never compute normals or worry about winding. MeshBuilder works out a normal for each face, and the shader turns it towards the camera if it points away, so a triangle looks right from either side.

Colour, gloss and glow

Each vertex stores its colour and two surface values, so one mesh can mix plaster walls, a slightly glossy tiled roof and a glowing window without separate draw calls. Gloss is what makes the roofs catch the low sun as the camera circles. The windows use glow, and the dusk preset is dark enough for it to matter: the village seems to switch on as the sky fades.

Laying out the village

Houses stand on three rings around a square of lamp posts: five on the first ring, ten on the second, fifteen on the third. Each angle and distance gets a little jitter so the rings do not look drawn with a compass. The yaw Math.PI / 2 - angle + Math.PI turns each front door towards the square. A belt of pines and round trees beyond the last ring closes the view.

Everything is added to one group object, village. To regenerate, the old group is removed with removeFromParent() and a new one is built from a new seed. The same seed always rebuilds the same village, which is how games keep procedural worlds identical between visits.

How much it costs

Each house is one mesh and one draw call of about seventy triangles. The whole village, trees and lamps included, is under eighty draw calls and about thirteen thousand triangles, comfortable on any GPU that runs WebGPU. If you ever need thousands of houses, merge them: build many into one MeshBuilder with withTransform and draw the whole street as a single object.

const street = new MeshBuilder();
for (let index = 0; index < 200; index++) {
  street.withTransform({ position: [index * 9, 0, 0] }, (builder) => {
    builder.box({ center: [0, 2, 0], size: [6, 4, 7], material: "#e8dcc8" });
  });
}
app.add(new SceneObject({ mesh: street.toMesh() }));

Things to try