How can I improve my chunk loading system for a procedurally generated voxel terrain?

So I created a chunk loading system for a voxel game I have been working on recently with expectations that I would eventually come up with something efficient and capable of providing a smooth, realtime experience to players. Instead, I am now stumped with code that regularly reaches up to 80% activity rates and I have no idea how to proceed from here regardless of all the research I have done across the past couple of weeks in desperation to find someone with similar problems to mine - obviously, with little success. I am quite pretentious so usually I would avoid asking for help, but this time it seems I have got myself in quite a difficult situation. Basically, I would like to know what I am doing that is making this code so inefficient and what possible ways there could be to improve things.

What I have is definitely an improvement from what I had before, but it is still not nearly enough to achieve that seamless experience I am aiming for. That being said, the code I would be sharing is around 300 lines long so instead of sharing a giant wall of text, here is an uncopylocked place because I don’t really know how to upload RBXL files.

Anyway, thanks for your time in advance. Obviously any help would be greatly appreciated.

2 Likes

Is there any particular reason the client is generating the terrain instead of the server?

2 Likes

I had received advice in the past that all things “graphics related” should be rendered on the client in interest of putting minimal stress on the server. However, I am quickly realizing that terrain may be one of those things that just have to be on the server, especially considering later on when I will be adding monsters and such things that will require a server-sided terrain to function properly.

3 Likes

I would agree that you need to have the server generate the map.

Here’s one of the many ways it can work:

  1. Every few seconds you can check on the server the position of player’s characters

  2. Check if chunks around the player’s character have already been generated where their character is positioned (if so, then there is no need to do anything else)

  3. Generate the chunks within a reasonable range of the player’s characters


To help the client out, you should turn on network streaming so the engine handles how many chunks of the map should be rendered on the client based on their hardware

2 Likes

On the topic of switching things over to the server - I have a couple questions:

  1. Should I be firing a RemoteEvent once every time the player moves into a new chunk with the server finding all the chunks around the player, or should the client be finding all the chunks near the character and telling the server every time a new chunk needs to be loaded? Or should I just skip RemoteEvents entirely and have the server handle position tracking and chunk rendering?

  2. If you reviewed over the code in the place I linked, would you happen to have any ideas in terms of what can be done to improve performance? Because functionality-wise it all works fine - its efficiency that it is really lacking right now.

A couple of extra notes - with the current system I have in place right now, a “reasonable” distance to be loading chunks around the character is about 2-3 chunks before the code starts causing severe lag and disrupting the game’s playability. Considering this, I am completely clueless as to what kind of dark magic a game like Minecraft used to achieve render distances of more than 16 chunks around the character while still maintaining a lag-free experience for players (if anyone knows their secrets I would love to know).

Another note - I have experimented with streaming services in the past and I have found that it is a little too “inconsistent” to provide any real help in this situation. Even after setting the target loading distance to the minimum of 64 studs, parts in the game of upward 300-400 studs away from the character failed to unload as I walked away from them (300-400 studs meaning about 15 chunks of unloaded terrain which would cause painful memory leaking). I have also found that there is a weird delay when it comes to unloading far-away regions when streaming is enabled, which is certainly not ideal for the near-instantaneous unloading that I am aiming for.

3 Likes

You should never trust the client to tell the server what to do. For example, I could pass information to the server to render virtually every chunk possible causing it the server to get hung up in requests and crash. Instead, you should have the server track the positions of characters and render chunks around each character.

I’ve noticed a significant amount of conditions being checked for every block being rendered which can cause it to take a lot longer. You should try to eliminate some of these checks if possible. In addition, if you use network streaming, then you can remove all code and checks pertaining to unloading which should speed the generation up.

This is intentional. The minimum distance means how much the client needs to always have rendered around them, and then it will continue to try to reach the target goal. Parts will only be removed based on the client’s hardware which is beneficial.


If you’re interested, I have an open-source place that utilizes network streaming to allow for virtually infinite terrain generation:

2 Likes

After seeing what you have achieved with streaming, I decided to take your advice about removing the code dealing with unloading old chunks and there were immediate improvements to performance. I also removed all conditions that were not necessary in generating a new block and I have narrowed it down to this set of code:

local BlockDensity = GetBlockDensity(x,y,z)

if BlockDensity > 0 and BlockDensity < .2  then
	local IsCaveWall = false
	local IsSurfaceBlock = false

	-- Find density values of adjacent blocks
	if GetBlockDensity(x,y+1,z) <= 0 then IsCaveWall = true; IsSurfaceBlock = true end
	if not IsCaveWall then
		if GetBlockDensity(x,y-1,z) <= 0 and not IsCaveWall then IsCaveWall = true end
		if GetBlockDensity(x+1,y,z) <= 0 and not IsCaveWall then IsCaveWall = true end
		if GetBlockDensity(x-1,y,z) <= 0 and not IsCaveWall then IsCaveWall = true end
		if GetBlockDensity(x,y,z+1) <= 0 and not IsCaveWall then IsCaveWall = true end
		if GetBlockDensity(x,y,z-1) <= 0 and not IsCaveWall then IsCaveWall = true end
	end
	
	if IsCaveWall then
		local BlockCF = CFrame.new(Vector3.new(3*x, 3*y, 3*z)) + Vector3.new(3,3,3)/2
		
		if IsSurfaceBlock == true then CreateBlock(game.ReplicatedStorage.PlacementModels.GrassBlock, BlockCF)
		else CreateBlock(game.ReplicatedStorage.PlacementModels.DirtBlock, BlockCF) end
	else
		return
	end
else
	return
end

Now, if possible, I would like to know if there is a better way to determine which blocks are exposed to the surface without relying on the density values of adjacent blocks because at this point the only condition for a block to spawn (that is impacting performance) is its own density must be above 0 and it must be exposed to the surface.

Another possible way I found that I could optimize things - skip iterating over any chunk that will not have any blocks generated inside it. But, of course, there is a problem with this - how would one predict whether or not a chunk will contain visible blocks without using iteration? I have been trying to work out a solution to this for the past couple of days and I have come up with something fairly efficient, but it does occasionally yield inaccurate results which produces weird holes in the terrain caused by these faulty predictions. Here is an image to demonstrate:
image

Here is the code handling these predictions:

local function RenderChunkPlane(RenderY)
	for RenderX = -RenderDistance, RenderDistance do 
		for RenderZ = -RenderDistance, RenderDistance do
			
			local ChunkPos = Vector3.new(ChunkMapPos.X+RenderX, ChunkMapPos.Y+RenderY, ChunkMapPos.Z+RenderZ)
			local ChunkPosWorld = ChunkPos*3*ChunkWidth
			local ChunkDistance = (RenderPos - ChunkPosWorld).magnitude
			local CharMoveDistance = (LastRenderPos - RenderPos).magnitude
			
			local ChunkXExist, ChunkYExist, ChunkZExist = ChunkExists(ChunkPos)
			
			if CharMoveDistance <= MaxWalkDistance then
				local _, BottomPos = GetChunkMapPos(ChunkPosWorld - Vector3.new(0,ChunkWidth,0)*1.5)
				local _, TopPos = GetChunkMapPos(ChunkPosWorld + Vector3.new(0,ChunkWidth,0)*1.5)
				
				local BottomDensity = GetBlockDensity(BottomPos.X, BottomPos.Y, BottomPos.Z)
				local BottomDensityCorner1 = GetBlockDensity(BottomPos.X+NearestInt(ChunkWidth/2), BottomPos.Y, BottomPos.Z+NearestInt(ChunkWidth/2))
				local BottomDensityCorner2 = GetBlockDensity(BottomPos.X-NearestInt(ChunkWidth/2), BottomPos.Y, BottomPos.Z+NearestInt(ChunkWidth/2))
				local BottomDensityCorner3 = GetBlockDensity(BottomPos.X+NearestInt(ChunkWidth/2), BottomPos.Y, BottomPos.Z-NearestInt(ChunkWidth/2))
				local BottomDensityCorner4 = GetBlockDensity(BottomPos.X-NearestInt(ChunkWidth/2), BottomPos.Y, BottomPos.Z-NearestInt(ChunkWidth/2))
				
				local TopDensity = GetBlockDensity(TopPos.X, TopPos.Y, TopPos.Z)
				local TopDensityCorner1 = GetBlockDensity(TopPos.X+NearestInt(ChunkWidth/2), TopPos.Y, TopPos.Z+NearestInt(ChunkWidth/2))
				local TopDensityCorner2 = GetBlockDensity(TopPos.X-NearestInt(ChunkWidth/2), TopPos.Y, TopPos.Z+NearestInt(ChunkWidth/2))
				local TopDensityCorner3 = GetBlockDensity(TopPos.X+NearestInt(ChunkWidth/2), TopPos.Y, TopPos.Z-NearestInt(ChunkWidth/2))
				local TopDensityCorner4 = GetBlockDensity(TopPos.X-NearestInt(ChunkWidth/2), TopPos.Y, TopPos.Z-NearestInt(ChunkWidth/2))
				
				local ChunkIsEmpty = false
				if BottomDensity <= 0 and BottomDensityCorner1  <= 0 and BottomDensityCorner2  <= 0 and BottomDensityCorner3  <= 0 and BottomDensityCorner4  <= 0 and TopDensity <= 0 and TopDensityCorner1 <= 0 and TopDensityCorner2 <= 0 and TopDensityCorner3 <= 0 and TopDensityCorner4 <= 0 then ChunkIsEmpty = true end
				if BottomDensity > 0 and BottomDensityCorner1  > 0 and BottomDensityCorner2  > 0 and BottomDensityCorner3  > 0 and BottomDensityCorner4  > 0 and TopDensity > 0 and TopDensityCorner1 > 0 and TopDensityCorner2 > 0 and TopDensityCorner3 > 0 and TopDensityCorner4 > 0 then ChunkIsEmpty = true end
				
				if not ChunkIsEmpty then
					RenderChunk(ChunkPos)
				end
			else
				CharacterMoved = true
				break
			end
		end
		if CharacterMoved then break end
	end
	end
	RenderChunkPlane(-1)
	RenderChunkPlane(0)
	for RenderY = -RenderDistance*2, RenderDistance*2 do
	if RenderY ~= 0 and RenderY ~= -1 then RenderChunkPlane(RenderY) end
	if CharacterMoved then break end
end

Basically, what the code is doing here is getting the density values of all 8 corners of the chunk and if all these values are either collectively above 0 (underground) or collectively below 0 (in the air) then the chunk is not going to contain blocks. Obviously, its not completely accurate and it all feels kind of hacky and inefficient so I figured that there is some kind of more simplistic method that I’m missing here?

Finally, after everything I have done so far to optimize my code based on your advice, there is a new problem - as you probably know already, using default smooth terrain is several times more efficient in terms of memory than using customized blocks made out of part instances. Because of this, after switching my game over to streaming enabled there was a strange lag building up as my character walked around and loaded more chunks, even with streaming set at minimum range. I would assume that this lag is being caused by the buildup of parts on the server as more chunks are loaded (because they are no longer being unloaded)? If you would like to take a look for yourself, I updated the uncopylocked place I shared with the new version of the code and streaming enabled.

1 Like

Another question I forgot to ask - how would I render the chunks that are closest to the character before the farther chunks are loaded? As it is currently, chunks are loaded from one corner of the render distance to the other corner, in the following format:

for offsetX = -RenderDistance, RenderDistance do
	for offsetY = -RenderDistance, RenderDistance do
		for offsetZ = -RenderDistance, RenderDistance do
			-- Chunk loading code here
		end
	end
end

I have a few general ideas of how I would approach solving this, for example something like this:

local RenderDistance = 15
local LoadedPositions = {}

for i = 0, RenderDistance do
	for x = -i, i do
		for y = -i, i do
			for z = -i, i do
				if not LoadedPositions[tostring(Vector3.new(x,y,z))] then -- check if chunk has been loaded before

					--Load new chunk

					LoadedPositions[tostring(Vector3.new(x,y,z))] = true
				end
			end
		end
	end
end

As usual, any feedback is appreciated. Thanks.

1 Like

Not to sound impatient, but is anyone going to be answering?