Creating our First Terrain Primitive
Simply using uniform fractal or multifractal noise is not enough for creating realistic terrains: we simply lack the variety found in real terrains. The compact procedural aspect of noise functions remains interesting - we are however limited by our ability to design a function that represents the desired landforms. In this part we will see how to design such shapes only using basic mathematical tools.
The core idea of this chapter is that procedural heightfields are not limited to noise: we can use other functions, such as trigonometric functions (cos, sin, tan) as well as geometric skeletons (explained in a bit). You may think of all of these as 1D or 2D functions of different shapes that we will combine together using simple operators such as addition and multiplication, to achieve a desired appearance. In concrete terms, we will design terrain primitives, and later combine them together to create a large-scale terrain.
This may still be a little abstract - Let's take an example: How about creating sand dunes? 🏜️
Designing Transverse Dunes
Sand dunes are a very good and educational example when it comes to creating terrain primitives. For those not familiar with how sand dunes look like, the figure below recaps the most common shapes identified by geomorphologists.
Sand dunes emerge from the interplay between wind of variable strength and direction over time, sand, and bare bedrock underneath. Here we focus on transverse dunes, which exhibit regular longitudinal patterns orthogonal to the wind direction.
The first observation is that the pattern is repeating over space at mostly regular intervals; the second observation is that the atomic repeating element is almost a straight line. If we take a side-view perspective, these dunes almost look like... a cosinus function. This is actually the only thing that we need to model such shape: we can use a 1D cosinus as our procedural heightfield function \(h\), taking as parameter the \(x\) or \(y\) coordinate of the point \(\mathbf{p}\):
\(h(\mathbf{p}) : \cos(\mathbf{p}_x)\)
function computeElevation(p) {
return Math.cos(p.x);
}
Put in 3D, it looks like this.
This is already quite good, although a bit 'hilly'. We are missing the sharp aspect of crests at the top of the dunes. This can done basically inverting the shape of our cosinus using an absolute value:
\(h(\mathbf{p}) : 1 - | \cos(\mathbf{p}_x) |\)
function computeElevation(p) {
return 1 - Math.abs(Math.cos(p.x));
}
Which leads to the following 3D result. Notice how we managed to create sharp features using a simple absolute value. This trick is similar to the one used to create ridge noise.
This is already pretty satisfying, can we do better? The result is still lacking some irregularities: nothing is as straight and as regular in real life. Noise is a great tool for such task: it is increadibly useful to add variety and details on terrain primitives. There are several ways to do this: for instance a small layer of noise may be added at the top or bottom of the dunes, or a warp the input position may be used to to add irregularities to the crest directions. You may refer to the interactive example below to compare the different versions with various additional parameters.
For the rest, the only limitation is your imagination and your ability to picture the combination of different mathematical functions. It's fun to do but still require a bit of practice.
Tip
It is sometimes easier to visualize and design shapes in 1D rather than 2D or 3D: for that, Desmos is a great tool that I use regularly. Shadertoy is also a great source of inspiration.
Designing a Single Mountain
Our sand dunes look relatively good. Now let's see how we can create a single mountain primitive. We don't want to create a mountain range here, just a single one that we can then combine with our dunes. How can we do that?
Maybe your first idea is to reuse the cosine function introduced before - except that here, we are not interested in the periodic aspect, so this might be a little less adapted. What we need is a punctual function that, given a location in the scene, will create the shape of a mountain. We are going to use what is often called a geometric skeleton.
Point Primitive
Let's start by using a Perlin Noise with a rather high amplitude everywhere, as seen in the previous part. This looks rather awful because there are peaks everywhere, while we just want a single one.
Geometric skeletons are control primitives: they are used to define where a given function should be active or inactive. Let's imagine the simplest skeleton, that has a value of 1 in a circle located at the origin (0, 0) of the scene, with radius 2. We combine our noise value \(n\) with the value returned by the skeleton \(s\).
\(h(\mathbf{p}) : s(\mathbf{p}) \cdot n(\mathbf{p})\)
function skeleton(x, y) {
let d = x * x + y * y; // distance to origin
if (d < 3.0)
return 1.0;
else
return 0.0;
}
function mountainPrimitive(x, y) {
let n = fBm(x, y); // noise
let s = skeleton(x, y); // skeleton
return n * s; // combination of the two.
}
We have successfully restricted the noise to a subpart of space. Notice how the radius controls the size of the skeleton. This still looks a bit off the function \(s\) is not continuous: it just takes values of 0 or 1. We can improve that by using the distance from the point \((x, y)\) to the origin, and dividing by the radius, like this:
function skeleton(x, y) {
let d = x * x + y * y; // distance to origin
if (d > stateGUI.Radius)
return 0.0;
else
return 1 - (d / stateGUI.Radius);
}
function mountainPrimitive(x, y) {
let n = fBm(x, y); // noise
let s = skeleton(x, y); // skeleton
return n * s; // combination of the two.
}
Notice how the surface has a nice, smooth transition between the mountain and the flat ground. There are other geometric skeleton that we will use in the next parts, but the point primitive is already enough for us to design a desert scene with dunes and mountains.
Putting it all together
We start with our transverse dune primitive from the previous section, and add the mountain primitive:
function dunePrimitive(x, y) {
let n = fBm(x, y);
return 1 - Math.abs(Math.cos(p.x + n));
}
function mountainPrimitive(x, y) {
let n = fBm(x, y);
let s = skeleton(x, y);
return n * s;
}
function computeElevation(x, y) {
let h1 = dunePrimitive(x, y);
let h2 = mountainPrimitive(x, y);
return h1 + h2;
}
And that's it: here is our first terrain with two different procedural functions, one for each landforms. Here we choose to add both elevations, but we will see other ways of combining different primitives together in the next chapters.
Going further
You may try to change the noise function: usually, ridge noise gives a more realistic vibe for mountains. You may also try to select which parts of the noise you want for your mountain While noise functions are self-similar, you may still need to select the parts with high positive amplitudes. This can be achieved by translating the input point \((x, y)\) by some arbitrary offset.
Associated files
Files associated with this page are available here.