Creating Procedural Mountains: A Fractal Noise Tutorial

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:

simplenoise

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:

perlinnoise

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

image

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:

fractalnoise

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 determine colorValue. 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.
:heart:

Did you enjoy my tutorial?
  • I loved it
  • I liked it
  • It was okay
  • I disliked it
  • I hated it

0 voters

107 Likes

This was a great tutorial, thank you. I tested it, and it all worked perfectly :wink: Nice, neat code, great for beginners or those not able to figure this out for themselves.

image

5 Likes

it is very laggy.
and it works.
can you show how to make it bigger?

1 Like

Absolutely stunning! Thanks for detailing out Procedural Terrain generation so beautifully. I learnt a lot!

Hoping to see more from you soon :)

3 Likes

Wow this is probably the best minecraft/roblox graphics I have ever seen good work

3 Likes

You can change the size of the terrain by adjusting SIZE_X and SIZE_Y in the final code snippet. This will allow you to change the size of the terrain as it alters how many x and y coordinates the for loop iterates over.

As for lag, there is no real performant solution for roblox on a large scale like this.
I would recommend exploring with chunk loading/unloading to simulate a StreamingService type setup. If you only need to calculate and generate chunks near the player, you reduce the lag a lot.

If you are generating chunks you also don’t need to Instance new parts for every coordinate. Since only so many blocks will be in the game per player, use PartCache to keep a store of cached parts and just move them to their required positions.

I hope this helps!

4 Likes

Out of all I’ve researched on this topic, this is one of the most useful and straight forward tutorials I have read. I will definitely keep a reference to this for a long time, and I hope others who decide to start looking for information on procedural generation can find it as well.

This is the only somewhat performant way so far to go, even Minecraft has a similar set up, albiet with a different design that is probably much more performant than anything we will make. Chances are it will lag even with chunks depending on your set up. However, if you put in the effort you can take it one step further and only generate what is visible to the player to an extent, reducing lag even more.

3 Likes

This is truly amazing, how Roblox devs always find out a new way to make games better at Roblox.

In your generateBlock function, multiply the position and size of the block by a scalar value before setting it.

Amazing tutorial. Ive been messing with Fractal and Perlin noise for a while, and this helped a lot

1 Like

Simply amazing, it’s a 10/10 to me, this is the best post I found, honest !

1 Like

How can I use this with 3d Perlin noise?

Just add a third argument to the generation code, for example:

math.noise(x, y, z)

But what difrennce does that make?

It adds an extra dimension to the noise, which is the thing you asked for.

If you only use x, y you’ll only get 2D noise.

Is there any chance that you could maybe also make a tutorial for generating actual terrain in the same kind of method? Because converting the blocks into terrain keeps the blockyness, and using the smooth tool over the whole map would be a pain.

I would look into modifying the generateBlock function to produce terrain voxels instead.

You can get pretty precise by using WriteVoxels to get the result you want.

Alright I’ll look into it, I’m hoping I no longer have to hand make all my terrain since roblox default generation is kinda bad

The ‘I hated it’ vote option must be satire, so I went with it. Fantastic tutorial!

7 Likes

Absolutely fantastic, I think multiple keywords could fit into the generation part, like lacunarity being intensity, and scale being zoom?