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.
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.
- Walls are one
box. Boxes shade their faces slightly differently, lightest on top and darkest underneath, a trick from City Drive that makes plain colours read as solid shapes. - The roof is two
quadcalls. Each runs from an eave at wall height up to the ridge, and the eaves overhang the walls by 30 centimetres on every side. - The gables are two
trianglecalls that fill the ends under the roof, in the wall colour. - Details are small boxes pushed two centimetres out from the wall: a door, two windows and a chimney. Three houses in four get glowing windows.
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
- Switch the environment to
"night"and raise the share of lit windows. - Add a well or a market stall in the square from cylinders and boxes.
- Drop in the character and controller from the previous tutorial and walk around the village. The houses already have colliders.