Voxel Engine [Part 1]

Hello, everyone!

This is my first ever tutorial here, so feel free to tell me some tips on how to improve it :slight_smile: (or criticize it as much as you can (plz no))

My english is also not the best with sometimes very limited vocabulary, so I apologize for all those grammar or other related mistakes I’ve definitely made in this text.

You’ve probably seen all those voxel engines (mostly MC copies) that are running extremely slow.
Well, in this tutorial series I will show you how to make one but FAST. (No, no, I mean voxel engine, not MC copy)

For comparison, this is a voxel engine I’ve made a while back. This is the speed we want to reach.

I expect that you have some experience with scripting, because I won’t be explaining the basics (like how tables work etc.) I will mostly focus on the algorithms themself and optimizations.

So let’s start with the most basic model.

Create a ‘Local Script’ and put it wherever you want (I recommend StarterGui (don’t kill me plz) because it’s going to be useful later) and open it

We want some cubes so let’s define a size for them:

local BLOCK_SIZE = 3

Now we can create some, so let’s do that in the most simple way:

for x = 1,16 do
	for y = 1,16 do
		for z = 1,16 do
			local part = Instance.new("Part")
			part.Anchored = true
			part.Size = Vector3.new(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE)
			part.Position = Vector3.new(x, y, z) * BLOCK_SIZE
			part.Parent = workspace
		end
	end
end

So basicly we loop through the x-, y- and z-axis, set every part’s size to our BLOCK_SIZE and offset them them by multiplying the position with the BLOCK_SIZE. This creates a big cube made out of smaller cubes. Let’s call that a chunk. And because the world is made out of chunks, we should also define a size for them, in this case 16, so let’s add that.

local CHUNK_SIZE = 16

Now we can replace those 16s with the CHUNK_SIZE. Our script should look like this so far:

local CHUNK_SIZE = 16
local BLOCK_SIZE = 3

for x = 1,CHUNK_SIZE do
	for y = 1,CHUNK_SIZE do
		for z = 1,CHUNK_SIZE do
			local part = Instance.new("Part")
			part.Anchored = true
			part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
			part.Position = Vector3.new(x, y, z) * BLOCK_SIZE
			part.Parent = workspace
		end
	end
end

Okay. We have a cube. But how do we add real terrain generation?
It is possible to do it in an extremely simple way, but we won’t do that here. You will see later why.

(The simple way for anyone wondering)
local CHUNK_SIZE = 16
local BLOCK_SIZE = 3

for x = 1,CHUNK_SIZE do
	for z = 1,CHUNK_SIZE do
		local noise = (math.clamp(math.noise(x/10,z/10,123.4),-1,1) + 1) * CHUNK_SIZE * 0.5
		noise = math.round(noise)
		for y = 1,noise do
			local part = Instance.new("Part")
			part.Anchored = true
			part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
			part.Position = Vector3.new(x, y, z) * BLOCK_SIZE
			part.Parent = workspace
		end
	end
end

So first let’s create a table that will store the data for each block. But what data?
All we need is just an id, from which we can tell what block it is.
So let’s create a table that holds information for all blocks in our chunk.
We well do it similary to how we created the blocks.
First create a new table with the size of our chunk

local data = table.create(CHUNK_SIZE)

Now that let’s add the other dimensions to the table by looping through and creating a new table for each index.

for x = 1,CHUNK_SIZE do
	data[x] = table.create(CHUNK_SIZE)
	for y = 1,CHUNK_SIZE do
		data[x][y] = table.create(CHUNK_SIZE, 0)
	end
end

Now we can access each block’s id like this:

local id = data[x][y][z]

With this data we should now edit our generation part so it works with the data.

for x = 1,CHUNK_SIZE do
	for y = 1,CHUNK_SIZE do
		for z = 1,CHUNK_SIZE do
			local id = data[x][y][z]
			if id ~= 0 then
				local part = Instance.new("Part")
				part.Anchored = true
				part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
				part.Position = Vector3.new(x, y, z) * BLOCK_SIZE
				part.Parent = workspace
			end
		end
	end
end

Let’s try it out!


nothing?
Yes, because we inicialized our data to 0 and 0 will be the id for air.

Let’s add some terrain generation now so we can see that it works.
How can we do that?
Well, we will use our data and edit it’s values, so if we want a block we set it to 1 otherwise we set it to 0! It’s that simple.

To generate some terrain we can use math.noise which we will use to determine the height.
I’m not going to go over every detail on how it works, but in a nutshell we give it coordinates and seed and it gives us a height for that specific block. If you want to read more about it I would recommend looking at this tutorial for basic understanding and this tutorial for more advanced noise.

So we need a height for each column of blocks. We can start by looping through the x and z axis and generating the height.

for x = 1,CHUNK_SIZE do
	for z = 1,CHUNK_SIZE do
		local height = math.clamp(math.noise(x / 10, z / 10, 123.4),-1,1) --10 for scaling, 123.4 as seed
	end
end

This will give us a number between -1 and 1. We can now use that like this for example:

height += 1 --Now the range is between 0-2
height *= CHUNK_SIZE --Now 0-(2 * CHUNK_SIZE)
height /= 2 -- Now 0-(2 * CHUNK_SIZE / 2) => 0-CHUNK_SIZE

We should also round it to make it aligned to the grid by rounding it.

height = math.round(height)

We have the height. So let’s edit the data so we finally get some blocks.
Let’s loop on the y-axis from 1 to the height and set all data in the range to 1.

for y = 1,height do
	data[x][y][z] = 1
end

The final code for generating should look like this:

for x = 1,CHUNK_SIZE do
	for z = 1,CHUNK_SIZE do
		local height = math.clamp(math.noise(x / 10, z / 10, 123.4),-1,1) --10 for scaling, 123.4 as seed
		height += 1 --Now the range is between 0-2
		height *= CHUNK_SIZE --Now 0-(2 * CHUNK_SIZE)
		height /= 2 -- Now 0-(2 * CHUNK_SIZE / 2) => 0-CHUNK_SIZE
		height = math.round(height)
		for y = 1,height do
			data[x][y][z] = 1
		end
	end
end

Okay, so this is what we got so far:

local CHUNK_SIZE = 16
local BLOCK_SIZE = 3

local data = table.create(CHUNK_SIZE)

for x = 1,CHUNK_SIZE do
	data[x] = table.create(CHUNK_SIZE)
	for y = 1,CHUNK_SIZE do
		data[x][y] = table.create(CHUNK_SIZE, 0)
	end
end

for x = 1,CHUNK_SIZE do
	for z = 1,CHUNK_SIZE do
		local height = math.clamp(math.noise(x / 10, z / 10, 123.4),-1,1) --10 for scaling, 123.4 as seed
		height += 1 --Now the range is between 0-2
		height *= CHUNK_SIZE --Now 0-(2 * CHUNK_SIZE)
		height /= 2 -- Now 0-(2 * CHUNK_SIZE / 2) => 0-CHUNK_SIZE
		height = math.round(height)
		for y = 1,height do
			data[x][y][z] = 1
		end
	end
end

for x = 1,CHUNK_SIZE do
	for y = 1,CHUNK_SIZE do
		for z = 1,CHUNK_SIZE do
			local id = data[x][y][z]
			if id ~= 0 then
				local part = Instance.new("Part")
				part.Anchored = true
				part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
				part.Position = Vector3.new(x, y, z) * BLOCK_SIZE
				part.Parent = workspace
			end
		end
	end
end

That’s all nice, but it can generate just one chunk. It should be able to create more of them. So let’s divide our code to functions and use them to create more chunks. That should be pretty straightforward. We are creating data, generating terrain data and creating the terrain it self.

local CHUNK_SIZE = 16
local BLOCK_SIZE = 3

local function createData()
	local data = table.create(CHUNK_SIZE)

	for x = 1,CHUNK_SIZE do
		data[x] = table.create(CHUNK_SIZE)
		for y = 1,CHUNK_SIZE do
			data[x][y] = table.create(CHUNK_SIZE, 0)
		end
	end	
	
	return data
end

local function generateTerrainData(data)
	for x = 1,CHUNK_SIZE do
		for z = 1,CHUNK_SIZE do
			local height = math.clamp(math.noise(x / 10, z / 10, 123.4),-1,1) --10 for scaling, 123.4 as seed
			height += 1 --Now the range is between 0-2
			height *= CHUNK_SIZE --Now 0-(2 * CHUNK_SIZE)
			height /= 2 -- Now 0-(2 * CHUNK_SIZE / 2) => 0-CHUNK_SIZE
			height = math.round(height)
			for y = 1,height do
				data[x][y][z] = 1
			end
		end
	end
	
	--No need to return data, tables are passed as references
	-- ==>
	--[[
	function edit(tab) 
		tab[1] = 8 
	end 
	local tab = {1,2,3} 
	edit(tab) 
	print(tab) --> {8,2,3}
	]]
end

local function createTerrain(data)
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				local id = data[x][y][z]
				if id ~= 0 then
					local part = Instance.new("Part")
					part.Anchored = true
					part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
					part.Position = Vector3.new(x, y, z) * BLOCK_SIZE
					part.Parent = workspace
				end
			end
		end
	end
end

Now we can combine them together and render that chunk again. It can be done like so:

local function createChunk()
	local data = createData()
	generateTerrainData(data)
	createTerrain(data)
end

Oh wait, the chunks have different positions, which means we must account for that. We can pass the chunk position to the createChunk function as an argument and give it to other functions. The chunk position will be equal to floor(position / CHUNK_SIZE / BLOCK_SIZE) to make it easier to work with later on.

Let’s start by editing the createTerrain function because it’s the easiest thing to start with. We know the formula for calculating chunk position so we can just reverse it to get the base position from it like so: base_position = chunk_position * CHUNK_SIZE * BLOCK_SIZE. Now we can just add it to the block’s position and we have everything we need. It’s time to do that in code.

local function createTerrain(data, chunk_position)
	local base_position = chunk_position * CHUNK_SIZE * BLOCK_SIZE
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				local id = data[x][y][z]
				if id ~= 0 then
					local part = Instance.new("Part")
					part.Anchored = true
					part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
					part.Position = base_position + Vector3.new(x, y, z) * BLOCK_SIZE
					part.Parent = workspace
				end
			end
		end
	end
end

The next thing we need to change is the generateTerrainData function, otherwise it will result in one chunk repeating over and over again. This is a little bit trickier but still pretty straightforward. First we must offset the x and z values we give to math.noise. It can be done like so:

local offsetX = x + chunk_position.X * CHUNK_SIZE
local offsetZ = z + chunk_position.Z * CHUNK_SIZE

And for the y-axis we can just subtract from the height and clamp it to a valid range like so:

height -= chunk_position.Y * CHUNK_SIZE
height = math.min(height,CHUNK_SIZE)

With this added the final function should look like this:

local function generateTerrainData(data, chunk_position)
	for x = 1,CHUNK_SIZE do
		for z = 1,CHUNK_SIZE do
			local offsetX = x + chunk_position.X * CHUNK_SIZE
			local offsetZ = z + chunk_position.Z * CHUNK_SIZE
			
			local height = math.clamp(math.noise(offsetX / 10, offsetZ / 10, 123.4),-1,1) --10 for scaling, 123.4 as seed
			height += 1 --Now the range is between 0-2
			height *= CHUNK_SIZE --Now 0-(2 * CHUNK_SIZE)
			height /= 2 -- Now 0-(2 * CHUNK_SIZE / 2) => 0-CHUNK_SIZE
			height = math.round(height)
			height -= chunk_position.Y * CHUNK_SIZE
			height = math.min(height,CHUNK_SIZE)
			for y = 1,height do
				data[x][y][z] = 1
			end
		end
	end
end

And let’s not forget to change the createChunk function which should now look like this:

local function createChunk(chunk_position)
	local data = createData()
	generateTerrainData(data, chunk_position)
	createTerrain(data, chunk_position)
end

I think it’s time to test it and see if it works so let’s make a 3,3,3 cube of chunks with simple for loops:

for x = -1,1 do
	for y = -1,1 do
		for z = -1,1 do
			createChunk(Vector3.new(x,y,z))
		end
	end
end

The result is great, but it really took some time to load. This is because our method of generating chunks is very VERY SLOW and we don’t want that. We want to make it FAST. There are multiple methods to achieve this like block culling or greedy meshing. Sadly greedy meshing won’t work really well with textures (I’m not using Texture/Decal/Anything like this instance for that and therefore can’t do greedy meshing. These Instances are extremly slow too and make the greedy mesher worthless in this case.) Both of these methods aim at redusing the amount of parts created, in our case of block culling, not creating blocks the player can’t see. Let’s see how much will the part count decrease with this. Right now, it creates 55049 parts.

So how does this algorithm work? It’s simple. Loop through chunk data and see if a block is neighbouring with air. If so, then create it, otherwise don’t. There’s nothing more to it.

Let’s do that in code. First we should create a new function to handle that. Let’s call it cullData. Inside the function we can loop through the data with again simple for loops like this:

local function cullData(data)
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				
			end
		end
	end
end

Inside the loops we get the block’s index from the x,y,z values and it’s id from data[x][y][z]. Let’s now get the neighbours of that block. We can get them from x+1, x-1, y+1, y-1, z+1, z-1. This is how it looks like in code:

--n_id = neighbour_id
local n_id1 = data[x+1][y][z]
local n_id2 = data[x-1][y][z]
local n_id3 = data[x][y+1][z]
local n_id4 = data[x][y-1][z]
local n_id5 = data[x][y][z+1]
local n_id6 = data[x][y][z-1]

Now let’s check if any of these is an air block. Remember the id for air? We set it to be 0 so it can just check if any of the neighbours are zero. This is the code for that:

if 
	n_id1 == 0 or
	n_id2 == 0 or
	n_id3 == 0 or
	n_id4 == 0 or
	n_id5 == 0 or
	n_id6 == 0 
then
					
end

Okay we have all that. Now we just need to somehow make it not create blocks. This is simple. We will just create new data and store the culled information there. We will write the block id to the culled data if it neighbours with air, otherwise we set it to 0. Here’s how it can look in code:

local function cullData(data)
	local culled_data = createData()
	
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				local my_id = data[x][y][z]
				
				--n_id = neighbour_id
				local n_id1 = data[x+1][y][z]
				local n_id2 = data[x-1][y][z]
				local n_id3 = data[x][y+1][z]
				local n_id4 = data[x][y-1][z]
				local n_id5 = data[x][y][z+1]
				local n_id6 = data[x][y][z-1]
				if 
					n_id1 == 0 or
					n_id2 == 0 or
					n_id3 == 0 or
					n_id4 == 0 or
					n_id5 == 0 or
					n_id6 == 0 
				then
					culled_data[x][y][z] = my_id
				else
					culled_data[x][y][z] = 0
				end
			end
		end
	end
	
	return culled_data
end

There’s still one problem tho because 1-1 is 0 and 0 is not a valid index for our array. That means it will error if we don’t stop it somehow. Again we can avoid it with really simple if statement. If x,y or z is 1 or CHUNK_SIZE, don’t check for neighbours. That means we should probaly write the data of that block to the culled data if the condition isn’t met because otherwise it will end up creating spaces with no blocks between the chunks.

Now it will defenitely work but there’s a small trick to make it faster (sometimes by a lot) and it’s also really simple. When we create the data it is defaultly initialized to zero so we can skip the cycle if my_id is equal to zero. This saves us time because we don’t write to the culled_data table.

Now if we combine all this with our existing code, it should end up looking like this:

local function cullData(data)
	local culled_data = createData()
	
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				local my_id = data[x][y][z]
				if my_id == 0 then
					continue
				end
				if 
					x == 1 or y == 1 or z == 1 or
					x == CHUNK_SIZE or y == CHUNK_SIZE or z == CHUNK_SIZE
				then
					culled_data[x][y][z] = my_id
				else
					--n_id = neighbour_id
					local n_id1 = data[x+1][y][z]
					local n_id2 = data[x-1][y][z]
					local n_id3 = data[x][y+1][z]
					local n_id4 = data[x][y-1][z]
					local n_id5 = data[x][y][z+1]
					local n_id6 = data[x][y][z-1]
					if 
						n_id1 == 0 or
						n_id2 == 0 or
						n_id3 == 0 or
						n_id4 == 0 or
						n_id5 == 0 or
						n_id6 == 0 
					then
						culled_data[x][y][z] = my_id
					else
						culled_data[x][y][z] = 0
					end
				end
			end
		end
	end
	
	return culled_data
end

Let’s put it in the createChunk function like so:

local function createChunk(chunk_position)
	local data = createData()
	generateTerrainData(data, chunk_position)
	data = cullData(data)
	createTerrain(data, chunk_position)
end

and see the difference in part count. Remember last time we tested it, it created 55049 parts.

And the result is… 19941 parts! That decreased a lot! (That’s around 36% of the original count.)

Now, I know it can be decreased much more but I will explain this in another part (maybe part 2-3?) because it can get really complicated and is just too long for this already long enough part.

Okay, we have a decently fast chunk generator. But let’s make it generate chunks around the player. But for this we must first modify our code a littlebit and make it remember which chunks are loaded. This will be very simple too. We will just modify the createTerrain function to put the blocks into a model and store a reference to that model in a table. (This is much faster than :FindFistChild because the table is a hashmap)

Let’s start by creating a model for the parts.

local parent = Instance.new("Model")

Then we will change the .Parent of the parts

part.Parent = parent

Now we need to store the models in a table so let’s create one at the top of the script and name it LOADED_CHUNKS. Now we can just store the reference to the model in the table like so:

LOADED_CHUNKS[chunk_position] = parent

And let’s not forget to parent the model to workspace (or a folder named chunks or anything else in workspace)

parent.Parent = workspace

This is the final look of the createTerrain function:

local function createTerrain(data, chunk_position)
	local base_position = chunk_position * CHUNK_SIZE * BLOCK_SIZE
	
	local parent = Instance.new("Model")
	
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				local id = data[x][y][z]
				if id ~= 0 then
					local part = Instance.new("Part")
					part.Anchored = true
					part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
					part.Position = base_position + Vector3.new(x, y, z) * BLOCK_SIZE
					part.Parent = parent
				end
			end
		end
	end

	LOADED_CHUNKS[chunk_position] = parent
					
	parent.Parent = workspace
end

Now we are ready to start generating chunks around player. But first we need to get the chunk position of the player. We can do that with a simple function:

local function getPlayersChunkPosition()
	if not game.Players.LocalPlayer.Character or not game.Players.LocalPlayer.Character:FindFirstChild("HumanoidRootPart") then
		return
	end
	local root = game.Players.LocalPlayer.Character.HumanoidRootPart
	local pos = root.Position / CHUNK_SIZE / BLOCK_SIZE
	return Vector3.new(math.floor(pos.X),math.floor(pos.Y),math.floor(pos.Z))
end

I think there’s no need to explain this and I believe you can figure out how it works.

The next step is to create a loop that checks for existing chunks around the player and if there isn’t one, generate it. Here’s a function for that which should be self explanatory:

local function generateChunksAroundPlayer()
	local position = getPlayersChunkPosition()
	for x = -1,1 do
		for y = -1,1 do
			for z = -1,1 do
				local targetPos = position + Vector3.new(x,y,z)
				if not LOADED_CHUNKS[targetPos] then
					createChunk(targetPos)
				end
			end
		end
	end
end

That’s nice, we can generate chunks around a player. But we also need to remove them if they are far enough. This can be done for example like this, which again should be self explanatory:

local function removeChunksFarFromPlayer()
	local playerPos = getPlayersChunkPosition()
	for position, instance in LOADED_CHUNKS do
		local diff = position - playerPos
		diff = Vector3.new(math.abs(diff.X),math.abs(diff.Y),math.abs(diff.Z))
		if diff.X > 2 or diff.Y > 2 or diff.Z > 2 then
			instance:Destroy()
			LOADED_CHUNKS[position] = nil
		end
	end
end

After that the last step is to put it in a while loop and we’re done!

while task.wait(0) do
	if not getPlayersChunkPosition() then
		continue
	end 
	generateChunksAroundPlayer()
	removeChunksFarFromPlayer()
end

Or are we? It seems like it’s lagging everytime we generate new chunks. We can fix that by optimizing the code (part 2-3?) or spreading the tasks to multiple frames. I like to use a simple function to check if the code has exceeded allowed time per frame and if so then wait for the next frame. Here is the code for that:

local lastWait = os.clock()
local function waitInNecessary()
	if os.clock() - lastWait > 0.005 then --5 ms
		task.wait(0)
		lastWait = os.clock()
	end
end

local function resetTimings()
	lastWait = os.clock()
end

Now we can just put it in a few places in the code and we are done!

The final code should look like this:
local CHUNK_SIZE = 16
local BLOCK_SIZE = 3

local LOADED_CHUNKS = {}

local lastWait = os.clock()
local function waitInNecessary()
	if os.clock() - lastWait > 0.005 then
		task.wait(0)
		lastWait = os.clock()
	end
end

local function resetTimings()
	lastWait = os.clock()
end

local function createData()
	local data = table.create(CHUNK_SIZE)

	for x = 1,CHUNK_SIZE do
		data[x] = table.create(CHUNK_SIZE)
		for y = 1,CHUNK_SIZE do
			data[x][y] = table.create(CHUNK_SIZE, 0)
		end
	end	

	return data
end

local function generateTerrainData(data, chunk_position)
	for x = 1,CHUNK_SIZE do
		for z = 1,CHUNK_SIZE do
			local offsetX = x + chunk_position.X * CHUNK_SIZE
			local offsetZ = z + chunk_position.Z * CHUNK_SIZE
			
			local height = math.clamp(math.noise(offsetX / 10, offsetZ / 10, 123.4),-1,1) --10 for scaling, 123.4 as seed
			height += 1 --Now the range is between 0-2
			height *= CHUNK_SIZE --Now 0-(2 * CHUNK_SIZE)
			height /= 2 -- Now 0-(2 * CHUNK_SIZE / 2) => 0-CHUNK_SIZE
			height = math.round(height)
			height -= chunk_position.Y * CHUNK_SIZE
			height = math.min(height,CHUNK_SIZE)
			for y = 1,height do
				data[x][y][z] = 1
			end
		end
		waitInNecessary()
	end
end

local function createTerrain(data, chunk_position)
	local base_position = chunk_position * CHUNK_SIZE * BLOCK_SIZE
	
	local parent = Instance.new("Model")
	
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				local id = data[x][y][z]
				if id ~= 0 then
					local part = Instance.new("Part")
					part.Anchored = true
					part.Size = Vector3.new(BLOCK_SIZE,BLOCK_SIZE,BLOCK_SIZE)
					part.Position = base_position + Vector3.new(x, y, z) * BLOCK_SIZE
					part.Parent = parent
					waitInNecessary()
				end
			end
		end
	end
	
	LOADED_CHUNKS[chunk_position] = parent

	parent.Parent = workspace
end

local function cullData(data)
	local culled_data = createData()
	
	for x = 1,CHUNK_SIZE do
		for y = 1,CHUNK_SIZE do
			for z = 1,CHUNK_SIZE do
				local my_id = data[x][y][z]
				if my_id == 0 then
					continue
				end
				if 
					x == 1 or y == 1 or z == 1 or
					x == CHUNK_SIZE or y == CHUNK_SIZE or z == CHUNK_SIZE
				then
					culled_data[x][y][z] = my_id
				else
					--n_id = neighbour_id
					local n_id1 = data[x+1][y][z]
					local n_id2 = data[x-1][y][z]
					local n_id3 = data[x][y+1][z]
					local n_id4 = data[x][y-1][z]
					local n_id5 = data[x][y][z+1]
					local n_id6 = data[x][y][z-1]
					if 
						n_id1 == 0 or
						n_id2 == 0 or
						n_id3 == 0 or
						n_id4 == 0 or
						n_id5 == 0 or
						n_id6 == 0 
					then
						culled_data[x][y][z] = my_id
					else
						culled_data[x][y][z] = 0
					end
				end
			end
		end
		waitInNecessary()
	end
	
	return culled_data
end

local function createChunk(chunk_position)
	local data = createData()
	generateTerrainData(data, chunk_position)
	data = cullData(data)
	createTerrain(data, chunk_position)
end

local function getPlayersChunkPosition()
	if not game.Players.LocalPlayer.Character or not game.Players.LocalPlayer.Character:FindFirstChild("HumanoidRootPart") then
		return
	end
	local root = game.Players.LocalPlayer.Character.HumanoidRootPart
	local pos = root.Position / CHUNK_SIZE / BLOCK_SIZE
	return Vector3.new(math.floor(pos.X),math.floor(pos.Y),math.floor(pos.Z))
end

local function generateChunksAroundPlayer()
	local position = getPlayersChunkPosition()
	for x = -1,1 do
		for y = -1,1 do
			for z = -1,1 do
				local targetPos = position + Vector3.new(x,y,z)
				if not LOADED_CHUNKS[targetPos] then
					createChunk(targetPos)
				end
			end
		end
	end
end

local function removeChunksFarFromPlayer()
	local playerPos = getPlayersChunkPosition()
	for position, instance in LOADED_CHUNKS do
		local diff = position - playerPos
		diff = Vector3.new(math.abs(diff.X),math.abs(diff.Y),math.abs(diff.Z))
		if diff.X > 2 or diff.Y > 2 or diff.Z > 2 then
			instance:Destroy()
			LOADED_CHUNKS[position] = nil
			waitInNecessary()
		end
	end
end

while task.wait(0) do
	if not getPlayersChunkPosition() then
		continue
	end 
	resetTimings()
	generateChunksAroundPlayer()
	removeChunksFarFromPlayer()
end

Now you can also play with the render distance in generateChunksAroundPlayer and removeChunksFarFromPlayer or other variables and see what works!

Here’s what render distance 2 looks like:

That’s all for now. I hope you enjoyed or learned something.

I am also planning to make part 2 for this where I would go over more optimizations, multithreading or texturing the parts. This could also be a really large topic so there might be part 3 for this and part 4 for micro optimizations and at the end reaching very good speed. (There could also be more parts, who knows… If I talk too much about some stuff…)

Have a nice day!

matulo2

12 Likes