← All posts

My terrain generator had no idea how big it was

Awaken's terrain plugin generates a whole island inside the editor: heightfield, rivers, lakes, roads, trees, rocks and grass. The brief I set was landscapes worth crossing on foot, with distinct regions, real mountains, and flat ground you could put a village on.

The second version of the generator did not meet it. It blended three noise recipes (mountains, hills, plains) by soft weights everywhere, so no cell was ever fully one thing. On default settings a world came out 20% land, all of it in the lowest tenth of the height range, peaking at 0.45 of that range, with no steep ground at all and 85 puddle "lakes" on the beach flats. Terrain lumps.

The v2 generator on seed 1234, top-down hillshade. 27% of the map is land, nearly all of it in the sand-coloured lowest band, and the highest point is 156 m out of a possible 400.

Plan first, then sculpt

Version 3 (biomes.ts) decides the world's structure before it computes a single height:

  • Regions come from a jittered grid of sites, domain-warped so their borders wander.
  • Mountain ranges cross the land as paths, each a continuous crest with ridged flanks, peaks and passes.
  • Climate follows from the layout: moisture near the coast, a rain shadow behind each range, cold with altitude.
  • Each region gets a biome, and each biome gets one landform recipe: flat plains, cliff-rimmed plateaus, mesa badlands, dune fields, a volcano cone.

Erosion, strata and the coast run afterwards. There are five presets: Continent, Highlands, Archipelago, Canyonlands and Volcanic isle.

The v3 generator on the same seed. Colours are the planned biome regions, the grey spine is a mountain range, and the brown line is a road joining four build sites. 68% land, highest point 332 m.

I tuned it with scripts/terrain-preview.mjs, which renders a seed as a top-down hillshade plus a stats line in a couple of seconds. Later I added pnpm terrain:shot, which runs the same world through the engine's real renderer, headless, from eye height. A hillshade shows where things are and says little about how a world looks. I learned that by calling a world flat from its hillshade when it measured 17 degrees of median slope.

The Highlands preset (seed 1234, a 2048 m world) rendered headless through the engine's renderer, looking back from its 357 m summit.

Getting that picture took a few attempts, because the world kept turning white. Once it was the ground shader calling dpdx inside an if, which WGSL rejects, so the material failed to compile and the renderer quietly fell back to an untextured default. Once it was the water sitting in the opaque pass, where it wrote itself into the depth prepass its own shader reads to find the shore, so every pixel of sea was zero metres from land and rendered as foam.

Giving "it looks wrong" a number

Judging terrain by eye kept going in circles: the islands were round blobs, then they were fine, then they were blobs again. So I wrote scripts/terrain-stats.mjs, which measures properties that real landscapes have published values for:

metric what it catches real landscapes
shoreline roundness, 4πA/P² islands that are blobs 0.05–0.7
shoreline fractal dimension a wobbly circle posing as a coast 1.15–1.30
hypsometric integral eroded land is concave; symmetric noise sits at 0.5 0.30–0.45
90th percentile slope steep relief should be concentrated 25–40°
drainage density valleys cut by water form a network 2–8 km/km²
peak radial symmetry a mountain that is a cone 0.3–0.6

The first run found the volcanic preset's peak at 0.866 radial symmetry with a hypsometric integral of 0.136: a cone standing on a pancake. Median slope on every preset was 13.7–28.0°, against 3–12° for real ground.

It also corrected me twice, and both times the mistake was in my measurement.

The first was drainage density. I had been quoting 8–16 km of channel per km² against a real 2–8 and saying the generator made far too many rivers. My metric started a channel at 1,280 m² of catchment, roughly twenty times too small, so it counted hillslope rills as rivers. Published figures use 10⁴–10⁵ m². At 2.5×10⁴ m² the same world measured 0.99 km/km²: too few rivers, the opposite conclusion.

The second was coastlines. The first run put shoreline roundness at 0.089–0.233, inside the real range, so I stopped worrying about blobs. Later I fixed a slope gate (more below) that had been spraying rock-roughness noise over 99.4% of the land, shoreline included. With the gate working, the same seed measured 0.770 roundness and 0.966 fractal dimension, below even a circle's 1.0. The metric had been faithful. It was measuring noise laid over a blob, and the blob had been there all along.

The generator never knew its own size

Under most of this sat one structural fault. The generator computed a 0..1 heightfield and the world's metres were applied afterwards; its parameters had no size and no height. That is fine for shape and wrong for every constant that models a physical process.

Take the angle of repose. Thermal erosion moves material when the height difference between neighbouring cells passes a threshold, and the threshold was a bare 0.008. The angle that represents depends on the world:

world grid angle
1024 m wide, 400 m relief 257 38.7°
1024 m wide, 400 m relief 1025 72.6°
4096 m wide, 400 m relief 257 11.3°
4096 m wide, 400 m relief 1025 38.7°

One setting, scree slopes anywhere from 11 to 73 degrees. Before the fix, median relief per four cells was identical at 1024, 2048 and 4096 m. The generator could not tell how big it was.

I went through the generator's twelve files and counted 149 constants like this, each a number whose physical meaning moves when world size, relief or grid resolution changes. Two of them were live bugs:

  • The "rock detail on steep faces only" gate was called with a stand-in heightfield claiming 6:1 relief, against a real world's 0.195. That overstated every slope 30.7 times, so the gate opened at 0.65 degrees and applied full strength to 99.4% of the land. This was the noise hiding the blob coastlines.
  • Build sites used a floor relative to sea level and a fixed absolute ceiling, so the band between them closed as the sea rose. At sea levels 0.25, 0.45, 0.60 and 0.70 the generator placed 4, 3, 0 and 0 sites. Roads join sites, so there were no roads either, and no error.

These now go through one units.ts and are stated in degrees, metres and square metres.

Relief does not scale with width

An 8 km world built with relief proportional to its width got 1600 m of it, a 1:5 ratio. Its median land slope was 28.5°, and 27.5% of the land was steeper than 45°. You could not walk on it.

Real islands are not proportional. Bora Bora's high point is 1:12 of its span, Tahiti 1:20, Madeira 1:31, Corsica 1:66. Small islands are relatively steeper than large ones, and that set fits a power law where relief grows as span to the power 0.64. On the 8 km world that gives 486 m, and median slope fell to 10.2°, with land over 45° down from 27.5% to 2.0%.

The 2 km default world had the same problem at the same 1:5 ratio. A cone that steep has a mean slope of 21° from geometry alone, so the 26.7° median I kept trying to tune away was close to the floor for that world, and no generator could have made it walkable.

Computing the landscape instead of drawing it

Fixing the units did not change what v3 is: every landform is a formula of position. The next plan inverts that. Stop computing height. Compute what makes height (rock uplift, erodibility, rainfall), let a landscape-evolution model erode it, and choose sea level last. Drainage area is the one non-local quantity in geomorphology, and the old generator had no term for it, which is why more noise never helped.

Planning it, I found more live bugs. The worst: flow accumulation did not know where sea level was, so 57.6% of the cells over the river-carving threshold were underwater. The ocean was the biggest river in the world, gouging channels into its own seabed.

In the model, rivers cut rock at a rate set by the water crossing each point and the slope there, hillslopes creep, and scree falls to its angle of repose. The river part is stream power, solved with Braun and Willett's implicit method, which runs in linear time because cells are ordered so each one's downstream neighbour already holds its final height. Two details I liked:

  • Water picks a downhill neighbour at random, weighted by slope to the sixth power, instead of always taking the steepest. Steepest descent can only point eight ways, so on near-flat ground whole regions pick the same direction and the network grows as an eight-pointed star. The random choice is hashed from the cell and seed, so it never changes between iterations.
  • Sea level is a quantile of the eroded heights, chosen after the solve. The valleys were cut with no idea where the sea would end up, so headlands are the ends of ridges and bays are drowned valley mouths. On an 8 km world, shoreline roundness went from 0.768 to 0.051 and fractal dimension from 0.967 to 1.18–1.28, inside the real range, with no coastline code involved.

The first full-resolution run took 246 seconds, mostly because a graph meant to join each high site to its neighbours joined every pair within a radius: 577 million distance tests per pass. With that and two smaller fixes, an 8 km world at res 513 solves in 9.5–15 s.

Tuning got easier too. Stream power at steady state gives S = U/(K√A), so the slope where a channel begins tells you the ratio of uplift to erodibility. Nobody can picture an erodibility, but anyone can picture an angle, so the controls became channel-head angle, hillslope length and range boost, and the solver's constants are derived from them. The best run hits six of nine targets on an 8 km world. Its misses include median slope (17.2° against 3–12°) and the share of land under 10° (16.1% against at least 25%).

This model is built and tested, and it is not switched on. Every world the editor makes today still comes from v3. The remaining gap has a known cause: shortening hillslopes to cut more valleys also steepens the median, because one diffusivity is doing both jobs. The literature separates them with non-linear hillslope transport, and that is the next piece of work.

What I would keep

The terrain improved fastest once I stopped tuning against my own eye. Every argument that went in circles ended when it had a number, and twice that number was wrong, which I only caught because the measurement lived in a script I could rerun. The cheaper habit: if a constant models something physical, give it the physical unit. Each of the 149 meant what I intended at one world size and something else at every other.

← All posts