Creating Procedural Mountains
This tutorial will cover generating procedural terrain using various types of noise as heightmaps. We will blend noise together to create convincing "Minecraft" style terrain. I will give you code snippets, diagrams and explanations so that hopefully you can recreate this yourself, and explore other types of terrain generation in the future.
The End Goal
Here is a video I posted to my twitter showcasing the terrain you will be able to create for yourself by the end of this tutorial.
For those of you more click-shy, here is an image:
Quick Disclaimer
I am in no way an expert in procedural generation. The information provided in this tutorial should not be assumed as canonical; I may use terminology incorrectly or explain things without the accuracy someone with more experience may have. Nonetheless, I do hope this tutorial helps you, and that you will be able to create some brilliant terrain on your own as a result.
This tutorial assumes intermediate proficiency in luau and basic highschool mathematics. If you don't have this, you may find understanding this tutorial difficult, but hopefully you can still follow along using my code snippets.
1.1 Getting Started
In this section we will use simple perlin noise to create a heightmap, and use that to create the many parts that will make up our terrain. For the sake of a basic tutorial, any code written today will be put in a single server-side script; although, in pursuit of clean code, you may wish to split parts of your project up into modules (especially if you mix in more types of noise in the future.)1.2 Creating Blocks
Before we begin experimenting with noise and creating our terrain, we need a function for creating blocks on our grid. This will help us visualize our noise as a heightmap.
local function generateBlock(x, y, z, color)
local height = 20
local block = Instance.new("Part")
block.Size = Vector3.new(1,height,1)
block.Position = Vector3.new(x, math.floor(y) - height / 2, z)
block.BottomSurface = Enum.SurfaceType.Smooth
block.TopSurface = Enum.SurfaceType.Smooth
block.Anchored = true
block.Color = color
block.Parent = workspace
end
This function should be fairly straight forward for most of you. It creates a 1×height×1 part at a specified position with a specified color.
“Why don’t we create 1×1×1 blocks?” you may ask. Well, since we are generating heightmaps, only 1 block can generate per 2D coordinate. This leaves us with vertical gaps between blocks where the slope is greater than 1:
A hacky method for fixing this this is by increasing the height of the blocks so that they are long rectangular prisms instead of blocks, thus filling the gaps. I chose a height of 20 arbitrarily. This is not a permanent solution, it is difficult to use this implementation of block generation in a game if you want to be able to mine blocks. I will show you how to fix this problem properly later in the tutorial, but for now, I will solve it simply.
It is important to note that I floor rounded y
so that the blocks snap to a vertical grid: this helps give a voxel game look. I also subtract half of the block’s height from its y
position so that the top of the blocks are centered around y=0
:
math.floor(y) - height / 2
This will help us greatly when we position the water layer later on.
1.3 Explaining Noise
Noise is a pseudorandom texture primitive. Its function takes a coordinate as an argument and returns a scalar value in a range, for example 0 to 1. Some noise functions can be very simple: say, for every 2D coordinate, the noise function randomly chooses a one or a zero. If we plot the returned value as a grayscale color, we would end up with something like this:The idea behind using noise in procedural generation is that we can use that value from 0 to 1 to determine the height of a piece of land.
So let’s try just that! Here is our simple “noise function”:
local function simpleNoise(x, y)
-- Create a pseudorandom generator using our coordinates as a seed
local randomGen = Random.new(x * y)
-- Return a random value, either 0 or 1
return randomGen:NextInteger(0, 1)
end
Let’s create a 50×50 piece of terrain using our noise function:
for x = 1, 50 do
for y = 1, 50 do
local height = simpleNoise(x, y)
generateBlock(x, height, y, Color3.new(163, 162, 165))
end
end
It looks more like a QR code than a mountain landscape, but it’s a start!
1.4 Understanding Perlin Noise
Our simple noise function is obviously not up to the task of generating mountains. Unlike other more appropriate noise functions, adjacent coordinates in our function could have completely different heights. There are also only two possible values: we could pick a random number between 0 or 1 but it would still look disorderly.Ken Perlin, a professor from NYU, developed a type of noise called perlin noise. Perlin noise is an example of gradient noise: instead of choosing random values, it interpolates between them, giving a smooth looking output. Here is some perlin noise plotted on our graph from before:
Notice the blurriness of the noise: instead of a disorderly arrangement of values, color is smoothed between dark and light. We can use this noise in our terrain to create much better land features.
1.5 Using Perlin Noise
Since perlin noise is so widely used, the Roblox math library has a function for generating perlin noise already, so we don't need to write the algorithm ourselves. It's syntax is:math.noise(x, y, z)
It accepts a 3D coordinate, but since we are only trying to generate 2D heightmaps, we will use z
as our seed parameter. It is important to note that this function returns a value from roughly -1 to 1, unlike our previous function. This will matter later in the tutorial, but we will leave it as-is for now.
Let’s generate some terrain with perlin noise!
I divide the coordinate inputs to math.noise()
by a noise scale value in order to change the scale of the noise.
I multiply the outputted noise value by a scale value in order to change the height of the terrain.
local SEED = 123 -- You may set this to os.time() if you'd like
local NOISE_SCALE = 20
local HEIGHT_SCALE = 10
for x = 1, 50 do
for y = 1, 50 do
local height = math.noise(x / NOISE_SCALE, y / NOISE_SCALE, SEED) * HEIGHT_SCALE
generateBlock(x, height, y, Color3.new(163, 162, 165))
end
end
Isn’t this awesome? We finally have something that can be passed off as terrain-looking. However, increasing the amount of terrain generated from 50×50, we will quickly see the problem with using perlin noise on its own:
It isn’t quite the mountainous landscape we are aiming for. In the next section we will layer perlin noise to create something more organic.
2.1 Leveling Up
In this section we will move away from using perlin noise on its own and instead layer multiple octaves of noise on top of eachother. This creates what is known as fractal noise.2.2 Fractal Noise
Fractal noise is generated by rescaling perlin noise and adding it into itself. Certain valleys and hills cancel eachother out and detail from multiple layers of noise builds up, creating more interesting and mountainous terrain. Here is a piece of fractal noise shown on our color diagram:A keen observer like yourself would notice that it retains the same smooth, interpolated look, but with more unique shapes and contours.
Let’s walk through how we would generate this noise:
Fractal noise works by repeatedly adding the result of the perlin noise function to a variable, rescaling the perlin noise function and then repeating this process of adding and rescaling for a number of iterations called octaves.
In each iteration the amount the octave is scaled by is called the lacunarity . We multiply the x
and y
coordinates by the lacunarity (lacunarity > 1) every iteration to slowly scale up the size of successive octaves. The scaling of the noise compounds every iteration, and so over the course of a few iterations, added noise gets more “zoomed in”.
Before being added to the previous noise values, every octave of noise is multiplied by the amplitude, a number that starts off with the value 1
. The amplitude represents how much of that octave should be added to the total value. Every iteration, we multiply the amplitude with a value called persistence to decrease the amplitude. You can think of persistence as a value (0 < persistence < 1) that determines how much successive octaves “persist”: smaller values decrease the amplitude by a greater fraction every iteration, causing successive octaves to “persist less” (have less of an effect on the end result.)
To summarize quickly, as our algorithm iterates, successive octaves of perlin noise are more “zoomed in” and are diminished in effect on the sum.
Here is a code snippet. I have explained the logic with comments:
local function fractalNoise(x, y, octaves, lacunarity, persistence, scale, seed)
-- The sum of our octaves
local value = 0
-- These coordinates will be scaled the lacunarity
local x1 = x
local y1 = y
-- Determines the effect of each octave on the previous sum
local amplitude = 1
for i = 1, octaves, 1 do
-- Multiply the noise output by the amplitude and add it to our sum
value += math.noise(x1 / scale, y1 / scale, seed) * amplitude
-- Scale up our perlin noise by multiplying the coordinates by lacunarity
y1 *= lacunarity
x1 *= lacunarity
-- Reduce our amplitude by multiplying it by persistence
amplitude *= persistence
end
-- It is possible to have an output value outside of the range [-1,1]
-- For consistency let's clamp it to that range
return math.clamp(value, -1, 1)
end
Next we will use our fractal noise function to generate terrain.
2.3 Using Fractal Noise
Using our new function is no different to when we used simple perlin noise, we just have more values to tweak:local SEED = 123
local NOISE_SCALE = 100
local HEIGHT_SCALE = 30
for x = 1, 100, 1 do
for y = 1, 100, 1 do
-- Generate our height value using fractal noise
local height = fractalNoise(x, y,
4, -- Octaves (Integer that is >1)
3, -- Lacunarity (Number that is >1)
0.35, -- Persistence (Number that is >0 and <1)
NOISE_SCALE,
SEED
) * HEIGHT_SCALE
-- Generate the block
generateBlock(x, height, y, Color3.new(163, 162, 165))
end
end
Tada! Play around with the constants to change the look of your terrain.
Here is a quick visual guide to show roughly how the different constants affect your terrain’s look. I would encourage you to play around and find what looks good to you, the possibilities are endless!
You can get very convincing results just by tweaking the arguments of your fractal noise function; however, I will cover improving your fractal noise function to suit mountain terrain specifically.
2.4 Further Improvements
The beauty of procedural generation is once you understand the basic concepts, you can tweak, blend and add things to get your generation to how you desire. Here are two tweaks to improve our mountain generation further. Firstly, get the absolute value of the output of the noise function before multiplying it with the amplitude, like so:value += math.abs(math.noise(x1 / scale, y1 / scale, seed)) * amplitude
This removes negative output values, which is important for our next tweak. Our next tweak is to square value
before returning it.
value = value ^ 2
Your new function should look like this:
local function fractalNoise(x, y, octaves, lacunarity, persistence, scale, seed)
local value = 0
local x1 = x
local y1 = y
local amplitude = 1
for i = 1, octaves, 1 do
value += math.abs(math.noise(x1 / scale, y1 / scale, seed)) * amplitude
y1 *= lacunarity
x1 *= lacunarity
amplitude *= persistence
end
value = value ^ 2
return math.clamp(value, -1, 1)
end
Squaring the value flattens the bases of the mountains, removing much of the valleys. This separates the mountains, making a more believable landscape:
I encourage you to play around with the function, experiment and see what changes improve your generation. Try making different biomes! The possibilities of procedural terrain generation don’t just stop at mountains.
In the next section we will add some color to our terrain.
3.1 Adventures in Color
The shape of our terrain is looking very mountain-like, but unless a volcano has recently erupted, spewing its gray ash over the landscape, the color isn't very realistic.The naive approach to coloring our mountains is to determine color strictly based on elevation. This is trivial to implement:
local color = nil
if height > 20 then
color = Color3.fromRGB(255, 255, 255) -- White
elseif height > 3 then
color = Color3.fromRGB(95, 97, 95) -- Gray
else
color = Color3.fromRGB(42, 86, 62) -- Green
end
The result however looks… meh. It feels like a layered cake.
Next we will try using perlin noise to give some variation to our colors.
3.2 Color Variation
To vary the color a little bit, we can use perlin noise to determine what color a block should be. This should be a fairly simple concept to grasp, just a lot of conditional statements. However, to save us from a death by if statement I would implement it using tables like so:local colorTable = {
{
Color = Color3.fromRGB(42, 86, 62), -- Green
NoiseScale = nil,
StartHeight = 0,
UseNoise = false,
Threshold = nil,
},
{
Color = Color3.fromRGB(95, 97, 95), -- Gray
NoiseScale = 30,
StartHeight = 3,
UseNoise = true,
Threshold = 0.4,
},
{
Color = Color3.fromRGB(255, 255, 255), -- White
NoiseScale = 20,
StartHeight = 10,
UseNoise = true,
Threshold = 0.5,
},
}
local color = nil
for _, colorData in ipairs(colorTable) do
if height >= colorData.StartHeight then
-- If we have determined that this color layer should use noise, perform the noise check
if colorData.UseNoise then
-- Generate a noise value to compare to our threshold
local colorValue = math.noise(x / colorData.NoiseScale, y/ colorData.NoiseScale, SEED * 123)
-- I add 1 and divide by 2 to change the range of the noise value from (-1, 1) to (0, 1)
colorValue = (colorValue + 1) / 2
if colorValue > colorData.Threshold then
color = colorData.Color
end
else
-- If the color layer shouldnt use noise, we set the color outright
color = colorData.Color
end
end
end
A huge improvement over the last method: the line between layers isn’t as well defined and the color looks patchy, which is nice.
I still think we can do better though; for areas (even high up in the mountain) where neither gray nor white generated, we get grass which is logically strange in the case of the summit of a mountain.
3.3 Blending Color Maps
The problem with grass appearing on top of the mountains can be solved by gradually fading between a perlin noise map and a constant value of 1 as you increase in elevation to determinecolorValue
. At a certain point, all of the blocks above a height would be snow, and as you get lower, it gets patchier.
We could use a smoothstep function to aid us here, but smoothstep functions are generally more complicated and thus out of the scope of this tutorial. I would recommend playing around with other smoothing functions; I personally used a modified smoothstep function to get the colors of the mountain showed at the very start of this post. Regardless, I will use a simpler “linearstep” function in this tutorial. The following function maps the value of c
between a
and b
in the range (0,1):
local function linearStep(a, b, c)
return math.clamp( (c - a) / (b - a), 0, 1)
end
Here is what our function looks like as a graph, with the x-axis showing the input value of c
, and the y-axis showing the output:
We can use this in our code to transition the colorValue
variable between a perlin noise map and a constant value of 1.
We pass colorData.StartHeight
into the a
parameter, we pass a new table value that we will name colorData.EndHeight
into b
(representing the height at which the transition will end) and finally, pass our generated height
into c
. We can then multiply colorValue
by one minus the result of this function and add the result of this function. You’ll understand what I mean when you read the code. This will smoothly interpolate between a patchy perlin noise map and constant flat color as we increase the height.
Let’s see some code in action:
local colorTable = {
{
Color = Color3.fromRGB(42, 86, 62), -- Green
NoiseScale = nil,
StartHeight = 0,
EndHeight = nil,
UseNoise = false,
Threshold = nil,
},
{
Color = Color3.fromRGB(95, 97, 95), -- Gray
NoiseScale = 5,
StartHeight = 5,
EndHeight = 20,
UseNoise = true,
Threshold = 0.6,
},
{
Color = Color3.fromRGB(255, 255, 255), -- White
NoiseScale = 10,
StartHeight = 15,
EndHeight = 30,
UseNoise = true,
Threshold = 0.7,
},
}
local function linearStep(a, b, c)
return math.clamp( (c - a) / (b - a), 0, 1)
end
local color = nil
for _, colorData in ipairs(colorTable) do
if height >= colorData.StartHeight then
-- If we have determined that this color layer should use noise, perform the noise check
if colorData.UseNoise then
-- Generate a noise value to compare to our threshold
local colorValue = math.noise(x / colorData.NoiseScale, y/ colorData.NoiseScale, SEED * 123)
-- I add 1 and divide by 2 to change the range of the noise value from (-1, 1) to (0, 1)
colorValue = (colorValue + 1) / 2
-- Apply our linear smoothing function
local stepResult = linearStep(colorData.StartHeight, colorData.EndHeight, height)
colorValue = (1 - stepResult) * colorValue + stepResult
if colorValue > colorData.Threshold then
color = colorData.Color
end
else
-- If the color layer shouldnt use noise, we set the color outright
color = colorData.Color
end
end
end
Here’s what it looks like:
Just like that we have a much better generation. This generation isn’t the best I have seen, but the more you tune the thresholds, scale and start/end heights the better your results will be. Try adding even more colors to your terrain! Using other smoothing functions can also improve the blending of colors: I encourage you to experiment.
I won’t release the exact smoothing function and tuning details I used for the mountains I showcased at the start (that would be bad teaching!), instead I hope that this tutorial gives you a strong foundation to create your own. Maybe yours will be even better than mine!
4.1 Finishing Up
This tutorial has been a long one and I am grateful you have read this far. In this last section we will add some basic water and finish properly solving the gap problem I outlined at the start of this tutorial.4.2 Hydrating our Planet
Adding water to our terrain is a simple task. We will just create and resize a translucent blue part. In a real game, you may want to fill in your rivers with water blocks, but this tutorial won't go there.As well as the blue part, I like to add another color layer below the green layer to give a riverbed color. It wouldn’t make sense to have water flowing over grass.
Combining these two modifications, you should get something that looks like this:
I will include the code in the final full code snippet.
4.3 Confronting Our Gap Problem
Alright. The tutorial is almost over, but I made a promise to properly address block creation so that we can avoid the gap problem. Confused? Maybe this image will jog your memory:We initially avoided this by scaling up the height of our parts, but I don’t like avoiding problems. I also don’t like gaps in my terrain—so let’s fix this!
Firstly let’s update our generateBlock()
function to no longer create rectangular prisms:
local function generateBlock(x, y, z, color)
local block = Instance.new("Part")
block.Size = Vector3.new(1, 1, 1)
block.Position = Vector3.new(x, math.floor(y), z)
block.BottomSurface = Enum.SurfaceType.Smooth
block.TopSurface = Enum.SurfaceType.Smooth
block.Anchored = true
block.Color = color
block.Parent = workspace
end
Another useful step to take is to separate our logic for calculating colors and heights into two separate functions, getColor()
and getHeight()
.
This should be self-explanatory, but you can refer to the full code snippet at the end of the tutorial if need be. We will be using these functions a lot in our block generation algorithm.
The logic behind the algorithm is as follows: check the four adjacent 2D coordinates of an originally generated surface block. If the floored height of the surface at an adjacent coordinate is greater than the floored height of the original block plus 1, we have a gap and so need to fill it. Filling in the gap is trivial, we find the amount of blocks tall the gap is by subtracting 1 and the original floored height of the generated block from the floored height of the surface. We can then loop this amount of times to fill in the correct number of blocks.
With a system like this, we only generate the outer shell of the terrain while avoiding holes and retaining a 3D grid of blocks. Unless you are caching previously generated surface block heights, this appears to be the most efficient algorithm available.
It is hard to explain logic in a paragraph, so here is some code:
-- Avoiding if statements through elegant usage of tables :)
local adjacentCoordinates = {
Vector2.new(1,0),
Vector2.new(0,1),
Vector2.new(-1,0),
Vector2.new(0,-1),
}
for x = 1, 100, 1 do
for y = 1, 100, 1 do
local height = getHeight(x, y)
local color = getColor(x, y, height)
generateBlock(x, height, y, color)
for _, offset in ipairs(adjacentCoordinates) do
local adjacentX = offset.x + x
local adjacentY = offset.y + y
local adjacentHeight = getHeight(adjacentX, adjacentY)
local flooredAdjacentHeight = math.floor(adjacentHeight)
local flooredHeight = math.floor(height)
if flooredAdjacentHeight > flooredHeight + 1 then
for i = 1, flooredAdjacentHeight - flooredHeight - 1 do
local newBlockHeight = adjacentHeight - i
local newBlockColor = getColor(adjacentX, adjacentY, newBlockHeight)
generateBlock(adjacentX, newBlockHeight , adjacentY, color)
end
end
end
end
end
And with that we can breathe easy. Crisis averted:
4.4 Where to Next?
Congratulations, you have made it to the end of my tutorial. Hopefully by now you have understood the basics of terrain generation, and are able to create stunning, pseudorandom landscapes of your own (or at least recreate my mountains).From here the possibilities are endless, procedural generation is such a vast topic with applications in texturing, terrain, map-making, dungeons, mazes and so much more.
Staying on the topic of terrain though, here are some ideas for projects you can make using today’s procedural terrain:
- Chunk loading/unloading to create infinite terrain
- A block mining system that makes the terrain appear solid, but in fact retains the “shell” of blocks we have created.
- Improved river/lake generation
- Caves using perlin worms or cellular automata
- Smoothly interpolated biomes with different color and height maps
4.5 Full Code Snippet
Here is the final code snippet, containing everything we made today organized neatly into functions with constants listed at the top:local SEED = os.time()
local NOISE_SCALE = 50
local HEIGHT_SCALE = 50
local OCTAVES = 4
local LACUNARITY = 3
local PERSISTENCE = 0.35
local SIZE_X = 200
local SIZE_Y = 200
local colorTable = {
{
Color = Color3.fromRGB(130, 130, 130),
NoiseScale = nil,
StartHeight = 0,
EndHeight = nil,
UseNoise = false,
Threshold = nil,
},
{
Color = Color3.fromRGB(42, 86, 62),
NoiseScale = nil,
StartHeight = 4,
EndHeight = nil,
UseNoise = false,
Threshold = nil,
},
{
Color = Color3.fromRGB(95, 97, 95),
NoiseScale = 5,
StartHeight = 5,
EndHeight = 20,
UseNoise = true,
Threshold = 0.6,
},
{
Color = Color3.fromRGB(255, 255, 255),
NoiseScale = 10,
StartHeight = 15,
EndHeight = 30,
UseNoise = true,
Threshold = 0.7,
},
}
local adjacentCoordinates = {
Vector2.new(1,0),
Vector2.new(0,1),
Vector2.new(-1,0),
Vector2.new(0,-1),
}
local function generateBlock(x, y, z, color)
local block = Instance.new("Part")
block.Size = Vector3.new(1, 1, 1)
block.Position = Vector3.new(x, math.floor(y), z)
block.BottomSurface = Enum.SurfaceType.Smooth
block.TopSurface = Enum.SurfaceType.Smooth
block.Anchored = true
block.Color = color
block.Parent = workspace
end
local function fractalNoise(x, y, octaves, lacunarity, persistence, scale, seed)
local value = 0
local x1 = x
local y1 = y
local amplitude = 1
for i = 1, octaves, 1 do
value += math.abs(math.noise(x1 / scale, y1 / scale, seed)) * amplitude
y1 *= lacunarity
x1 *= lacunarity
amplitude *= persistence
end
value = value ^ 2
return math.clamp(value, -1, 1)
end
local function linearStep(a, b, c)
return math.clamp( (c - a) / (b - a), 0, 1)
end
local function getColor(x, y, height)
local color = nil
for _, colorData in ipairs(colorTable) do
if height >= colorData.StartHeight then
if colorData.UseNoise then
local colorValue = math.noise(x / colorData.NoiseScale, y/ colorData.NoiseScale, SEED * 123)
colorValue = (colorValue + 1) / 2
local stepResult = linearStep(colorData.StartHeight, colorData.EndHeight, height)
colorValue = (1 - stepResult) * colorValue + stepResult
if colorValue > colorData.Threshold then
color = colorData.Color
end
else
color = colorData.Color
end
end
end
return color
end
local function getHeight(x, y)
return fractalNoise(x, y,
OCTAVES,
LACUNARITY,
PERSISTENCE,
NOISE_SCALE,
SEED
) * HEIGHT_SCALE
end
for x = 1, SIZE_X, 1 do
for y = 1, SIZE_Y, 1 do
local height = getHeight(x, y)
local color = getColor(x, y, height)
generateBlock(x, height, y, color)
for _, offset in ipairs(adjacentCoordinates) do
local adjacentX = offset.x + x
local adjacentY = offset.y + y
local adjacentHeight = getHeight(adjacentX, adjacentY)
local flooredAdjacentHeight = math.floor(adjacentHeight)
local flooredHeight = math.floor(height)
if flooredAdjacentHeight > flooredHeight + 1 then
for i = 1, flooredAdjacentHeight - flooredHeight - 1 do
local newBlockHeight = adjacentHeight - i
local newBlockColor = getColor(adjacentX, adjacentY, newBlockHeight)
generateBlock(adjacentX, newBlockHeight , adjacentY, color)
end
end
end
end
end
local water = Instance.new("Part")
water.BottomSurface = Enum.SurfaceType.Smooth
water.TopSurface = Enum.SurfaceType.Smooth
water.Anchored = true
water.Parent = workspace
water.Size = Vector3.new(SIZE_X, 0, SIZE_Y)
water.Position = Vector3.new(SIZE_X / 2, 2.95, SIZE_Y / 2)
water.Color = Color3.fromRGB(102, 243, 255)
water.Transparency = 0.7
5.1 Plenary
10 hours later, I have arrived at the end of my first tutorial. It has been a lot of fun experimenting with procedural generation and trying to explain the topic to other people.Feel free to follow my twitter. (shameless self plug)
If you have any suggestions, questions or concerns please let me know in the comments.
Thank you all for reading; I hope my tutorial has been interesting and useful.
- I loved it
- I liked it
- It was okay
- I disliked it
- I hated it
0 voters