Volume without a modelling tool: triangulating an island by hand

The rock on this island is not exported from a modelling tool. It is five irregular rings stitched together at runtime, and vertex colours plus flat shading are enough to make it read as stone.

The island’s rock comes from no modelling tool and no .glb file. It is five irregular rings stitched together at runtime, in about forty lines.

Think of the island as a stack of rings

From above the island is a circle; from the side it is a stack of rings that get smaller and lower. So define five of them:

const rings = [
  [radius * 0.94, 0.45],
  [radius, -0.05],
  [radius * 0.88, -depth * 0.28],
  [radius * 0.64, -depth * 0.66],
  [radius * 0.25, -depth],
];

Each ring is then cut into 19 segments at fixed angles, with a small random wobble on radius and height. The wobble is tiny (radius ±6%, height ±0.13) but it is exactly what stops the outline from being a perfect circle — and a perfect circle is what makes procedural geometry look procedural.

Triangulating by hand, two loops deep

Two adjacent rings and two adjacent segments make a quad; each quad becomes two triangles. No index buffer, vertices pushed straight into arrays:

for (let j = 0; j < rings.length - 1; j += 1) {
  for (let i = 0; i < segments; i += 1) {
    const a = ringPoints[j][i];
    const b = ringPoints[j][(i + 1) % segments];
    const c = ringPoints[j + 1][i];
    const d = ringPoints[j + 1][(i + 1) % segments];
    triangle(a, b, c, shades[Math.floor(random(0, shades.length))]);
    triangle(b, d, c, shades[Math.floor(random(0, shades.length))]);
  }
}

Each triangle picks a shade at random. This is the best value in the whole file: vertex colours plus flat shading give every face its own normal, and the random colour makes it look like broken rock rather than a smooth surface.

Do not forget the underside

The last ring cannot just be left open — from below you would see straight into the island. So add one more fan of triangles, all collapsing into a single centre point:

triangle(last[i], last[(i + 1) % segments], vec(0, -depth * 1.12, 0), '#344b56');

That centre point sits lower than the last ring, so the underside is a cone. Which is what a floating island should look like from underneath anyway.

The grass is just two cylinders

Nothing above the rock needs real terrain. Two flat cylinders will do: a green one slightly narrower than the rock’s top face, and a thinner, lighter one on top to form a terrace edge. That ring of contrasting colour says “you could stand here” better than any amount of detail.

Shrubs, mossy stones and flower heads are handed to instancing; their only job is to break up the regularity of the cylinders.

The fragments reuse the same function

The three drifting fragments outside the main island call the same makeRock() with radii between 0.48 and 0.77 instead of 4.65. Because they share the same wobble, they look like the same geology. That is not a coincidence — it is the free consistency you get from reuse.

Fix the seed

Every random() call comes from a linear congruential generator seeded with 413. Two benefits: the island is identical on every build, so a reader can share “the island I saw”; and debugging does not change the mountain every time you refresh.

Closing

The hard part of procedural geometry is not the maths, it is choosing what to spend vertices on: rock strata and shading get geometry, vegetation gets instancing, and the lawn is two cylinders and a colour difference. Get those three tiers right and forty lines is a whole island.

← Back to all posts

Comments

…