Using Fractal Noise To Create 3D Terrain With Caves

Hello, I am @MightyPart and this is a tutorial on how to use Fractal Noise to create 3D Terrain with caves.

Please tell me below if you get stuck or are confused. English is my native language but its currently 3am.

What is Perlin Noise?

Perlin noise is a type of visual noise that consists of a smoothly-varying signal with an organic feel than pure random noise.

What is Fractal Noise?

Fractal noise is multiple layers of perlin noise that are combined together to form a more intricate version of perlin noise.


Section 1: Setting Up The Project

Create a new Roblox Studio Project and remove everything from the workspace. Then add a Script called GenTerrain into ServerScriptService.

Add these variables into the GenTerrain Script:

WIDTH, DEPTH, HEIGHT = 50, 50, 30

PART_SCALE = 5
NOISE_SCALE = 100
HEIGHT_SCALE = 30

OCTAVES = 4
LACUNARITY = 3
PERSISTENCE = .35
SEED = 69

MAX_TREES = 50

ValidTreePositions = {}

CollectionService = game:GetService("CollectionService")

Add this function near the top of the GenTerrain Script:

local function Round(num, mult)
	return math.floor(num / mult + 0.5) * mult
end

Section 2: Creating The Grid

To start off we need to create a grid of parts. We can achieve this by adding this code to the GenTerrain Script:

for x=0,WIDTH*PART_SCALE,PART_SCALE do
	for z=0,DEPTH*PART_SCALE,PART_SCALE do
		for y=0,HEIGHT*PART_SCALE,PART_SCALE do

			local part = Instance.new("Part")
			part.Anchored = true
			part.Size = Vector3.new(PART_SCALE, PART_SCALE, PART_SCALE)
			part.Position = Vector3.new(x,y,z)
			
			part.Parent = workspace

		end
	end
end

When you press Run, this grid that consists of many parts should be the result.


Section 3: Creating The Fractal Noise

Add a new ModuleScript called FractalNoise into the GenTerrain Script.

Add the code below into the FractalNoise ModuleScript:

FractalNoise = {}

FractalNoise["2D"] = function(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.noise(x1 / scale, y1 / scale, seed) * amplitude
		y1 *= lacunarity
		x1 *= lacunarity
		amplitude *= persistence
	end
	return math.clamp(value, -1, 1)
end

return FractalNoise

The function above creates layered noise based on the arguments put into it. For more info about this function you should check out BuiltToWreck’s post where they explain more about it.

Now import the FractalNoise ModuleScript function into the GenTerrain Script by adding in this line of code (ideally near the top of the Script):

FractalNoise = require(script.FractalNoise)

The nested for loops from Section 2 can now be changed to include the Fractal Noise:

for x=0,WIDTH*PART_SCALE,PART_SCALE do
	for z=0,DEPTH*PART_SCALE,PART_SCALE do
		
		local TwoD_height = FractalNoise["2D"](x, z,
			OCTAVES,
			LACUNARITY,
			PERSISTENCE,
			NOISE_SCALE,
			SEED
		) * HEIGHT_SCALE
		TwoD_height = Round(TwoD_height, PART_SCALE)
		TwoD_height += (HEIGHT*PART_SCALE) - HEIGHT_SCALE
		
		for y=0,HEIGHT*PART_SCALE,PART_SCALE do
			
			if y > TwoD_height then continue end

			local part = Instance.new("Part")
			part.Anchored = true
			part.Size = Vector3.new(PART_SCALE, PART_SCALE, PART_SCALE)
			part.Position = Vector3.new(x,y,z)
			
			part.Parent = workspace

		end
	end
end

When you press Run, the top of the grid should now be reminiscent of terrain:


Section 4: Making The Caves

To make the caves we need to add another function into the FractalNoise ModuleScript. This function should be situated above return FractalNoise:

FractalNoise["3D"] = function(x, y, z, octaves, lacunarity, persistence, scale, seed)
	local value = 0 
	local x1 = x 
	local y1 = y
	local z1 = z
	local amplitude = 1
	for i = 1, octaves, 1 do
		value += math.noise(x1 / scale, y1 / scale, z1 / scale, seed) * amplitude
		y1 *= lacunarity
		x1 *= lacunarity
		z1 *= lacunarity
		amplitude *= persistence
	end
	return math.clamp(value, -1, 1)
end

Now edit the nested for loops inside of the GenTerrain Script:

for x=0,WIDTH*PART_SCALE,PART_SCALE do
	for z=0,DEPTH*PART_SCALE,PART_SCALE do
		
		local TwoD_height = FractalNoise["2D"](x, z,
			OCTAVES,
			LACUNARITY,
			PERSISTENCE,
			NOISE_SCALE,
			SEED
		) * HEIGHT_SCALE
		TwoD_height = Round(TwoD_height, PART_SCALE)
		TwoD_height += (HEIGHT*PART_SCALE) - HEIGHT_SCALE
		
		for y=0,HEIGHT*PART_SCALE,PART_SCALE do
			
			if y > TwoD_height then continue end
			
			local ThreeD_height = FractalNoise["3D"](x, y, z,
				OCTAVES,
				LACUNARITY,
				PERSISTENCE,
				20,
				SEED
			) * HEIGHT_SCALE
			ThreeD_height = Round(ThreeD_height, PART_SCALE)

			if ThreeD_height < 0 and y ~= TwoD_height then continue end

			local part = Instance.new("Part")
			part.Anchored = true
			part.Size = Vector3.new(PART_SCALE, PART_SCALE, PART_SCALE)
			part.Position = Vector3.new(x,y,z)
			
			part.Parent = workspace

		end
	end
end

When you press Run, you will see that the terrain now has caves underneath it.


Section 5: Adding Color

To add color to the terrain add this if statement to the nested for loop inside of the GenTerrain Script:

if y == TwoD_height then
	part.Color = Color3.fromRGB(115, 142, 112)
	part.Material = Enum.Material.Grass
	table.insert(ValidTreePositions, Vector3.new(x,y,z))
	
elseif y <= TwoD_height-PART_SCALE and y >= (TwoD_height-(PART_SCALE*3)) then
	part.Color = Color3.fromRGB(140, 95, 56)
	part.Material = Enum.Material.Slate
	
else
	part.Color = Color3.fromRGB(143, 146, 146)
	part.Material = Enum.Material.Rock
end
The nested for loop should now look like this
for x=0,WIDTH*PART_SCALE,PART_SCALE do
	for z=0,DEPTH*PART_SCALE,PART_SCALE do
		
		local TwoD_height = FractalNoise["2D"](x, z,
			OCTAVES,
			LACUNARITY,
			PERSISTENCE,
			NOISE_SCALE,
			SEED
		) * HEIGHT_SCALE
		TwoD_height = Round(TwoD_height, PART_SCALE)
		TwoD_height += (HEIGHT*PART_SCALE) - HEIGHT_SCALE
		
		for y=0,HEIGHT*PART_SCALE,PART_SCALE do
			
			if y > TwoD_height then continue end
			
			local ThreeD_height = FractalNoise["3D"](x, y, z,
				OCTAVES,
				LACUNARITY,
				PERSISTENCE,
				20,
				SEED
			) * HEIGHT_SCALE
			ThreeD_height = Round(ThreeD_height, PART_SCALE)

			if ThreeD_height < 0 and y ~= TwoD_height then continue end

			local part = Instance.new("Part")
			part.Anchored = true
			part.Size = Vector3.new(PART_SCALE, PART_SCALE, PART_SCALE)
			part.Position = Vector3.new(x,y,z)
			
			if y == TwoD_height then
				part.Color = Color3.fromRGB(115, 142, 112)
				part.Material = Enum.Material.Grass
				table.insert(ValidTreePositions, Vector3.new(x,y,z))

			elseif y <= TwoD_height-PART_SCALE and y >= (TwoD_height-(PART_SCALE*3)) then
				part.Color = Color3.fromRGB(140, 95, 56)
				part.Material = Enum.Material.Slate

			else
				part.Color = Color3.fromRGB(143, 146, 146)
				part.Material = Enum.Material.Cobblestone
			end
			
			part.Parent = workspace

		end
	end
end

When you press Run , Your terrain should now have some color on it.


Section 6: Adding Trees

To give your island a sense of live you could add some trees. First get my tree model here. Add the tree to ReplicatedStorage.

Add this for loop to the bottom of the GenTerrain Script.

for count=1,MAX_TREES do
	local tree = game.ReplicatedStorage.Tree:Clone()
	local pos = ValidTreePositions[math.random(1, #ValidTreePositions)]

	local cframe = CFrame.new(
		pos.X, pos.Y+(tree:GetExtentsSize().Y/2)-2, pos.Z
	) * CFrame.Angles(0, math.rad(math.random(1, 360)), 0)

	tree:SetPrimaryPartCFrame(cframe)
	tree.Parent = workspace
end

When you press Run, your terrain will now have Trees on top of it.


Section 7: Making Blocks Breakable

Add the code below into the nested for loops inside of the GenTerrain function right above the part.Parent = workspace line:

clickDetector = Instance.new("ClickDetector")
clickDetector.Parent = part
			
CollectionService:AddTag(part, "Blocks")

Add the for loop below to the end of the GenTerrain script:

for _,block in pairs(CollectionService:GetTagged("Blocks")) do
	block.ClickDetector.MouseClick:Connect(function()
		block:Destroy()
	end)
end

When you press Run, you will now be able to remove blocks.


Section 8: Finishing Touches

Currently the player can fall through the bottom of the map, to fix this we can add a part at the bottom of the terrain to make sure players can’t fall through holes in the bottom of the map.

To do this add this code to the bottom of the GenTerrain Script.

local bottomRock = Instance.new("Part")
bottomRock.Anchored = true
bottomRock.Size = Vector3.new((WIDTH*PART_SCALE)+PART_SCALE, PART_SCALE, (DEPTH*PART_SCALE)+PART_SCALE)
bottomRock.Position = Vector3.new((WIDTH*PART_SCALE)/2, -PART_SCALE, (DEPTH*PART_SCALE)/2)
bottomRock.Parent = workspace
bottomRock.Color = Color3.fromRGB(116, 114, 115)
bottomRock.Material = Enum.Material.Pebble

When you press Run, there will now be a layer of rock preventing the player from falling through holes in the bottom of the map.

19 Likes

I really liked this tutorial! I used it to make minecraft terrain. However, could you please add a part about Greedy meshing as I can’t wrap my head around how I could implement it

(Also I added ore, I could update the post on how to do that)

3 Likes

Add to the tutorial a system that makes it so only blocks surrounded by air get rendered. You will get at most a 10x performance increase.

4 Likes

That is a good idea. Maybe I should add that to my edit. (Although, I don’t think im smart enough)

3 Likes

Heyo, don’t know if you where still working on this but here is a code snipped that im using that uses greedy meshing for the y axis. You could use it for the x & the z axis if you wanted to but im doing a noise graident color so doing greedy meshing on the x & z messes it up. Hope this helps ya!

local function OptimizeTerrain()
	for X = 1, GRID_SIZE_2D do
		Multithread(function()
			for Y = 2, GRID_SIZE_3D do
				for Z = 1, GRID_SIZE_2D do
					if not TerrainGrid[X][Y][Z] then
						continue
					end

					if not TerrainGrid[X][Y - 1][Z] then
						continue
					end

					TerrainGrid[X][Y][Z]["Size"] += Vector3.new(
						0,
						TerrainGrid[X][Y - 1][Z]["Size"].Y,
						0
					)
					TerrainGrid[X][Y - 1][Z] = nil
				end
				
				task.wait()
			end
		end)
		
		print(string.format("Optimizing Terrain: %d%s", X / GRID_SIZE_2D * 100, "%"))
		task.wait()
	end
end
1 Like

If you don’t mind me asking, have you tested how much performance this has successfully saved?

No I have not done exact tests however it will take longer to generate as you are adding another step but it reduces part count by a large amount (~300% less parts)

Here is a video of it in action: (Note: this is FBM + 3D Noise)

This seems rather good, if you were to make this in parallel luau, you can effectively make seamless chunks generate with little to no lag.

Yeah I was just making some testing, if I where to create this with a chunk system then I would for sure use that!