Help with terrain generation (voxel)

I’m trying to procedurally generate minecraft-like terrain and am able to do the first layer, but am struggling with lag and performance issues. Everytime new chunks are loaded the player gets a lag spike, and if I try generating layers under the first its even worse. Whats the best way of doing this, heres my code so far:

-- Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- Chunks and Blocks variables/settings
local chunksFolder = workspace:WaitForChild("Chunks")

local loadedChunks = {}

local chunkSize = 16
local serverRenderDistance = 5

local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")

local blockSize = blocksFolder.Grass.Size

-- Map settings
local amplitude = 0.4

-- Perlin Noise setup and variables/settings
math.randomseed(os.time())
local noiseOffsetX = math.random(0, 10000)
local noiseOffsetZ = math.random(0, 10000)

-- Functions
local function worldToBlockCoordinates(position: Vector3)
	local blockX = math.floor(position.X / blockSize.X)
	local blockY = math.floor(position.Y / blockSize.Y)
	local blockZ = math.floor(position.Z / blockSize.Z)

	return Vector3.new(blockX, blockY, blockZ)
end

local function placeBlock(block: Instance, position: Vector3, parent: Instance)
	local generatedBlock = block:Clone()
	generatedBlock.Position = position

	if parent then
		generatedBlock.Parent = parent
	else
		generatedBlock.Parent = workspace
	end

	return generatedBlock
end

local function generateChunk(chunkX: number, chunkZ: number)
	local chunkExists = loadedChunks["X: "..chunkX.." Z: "..chunkZ]
	if chunkExists then return end

	local chunkFolder = Instance.new("Folder", chunksFolder)
	chunkFolder.Name = "X: "..chunkX.." Z: "..chunkZ

	local chunkData = {}
	for x = 1, chunkSize do
		chunkData[x] = {}
		for z = 1, chunkSize do
			local noiseValue = math.noise(((chunkX - 1) * chunkSize + x + noiseOffsetX) / 10, ((chunkZ - 1) * chunkSize + z + noiseOffsetZ) / 10)
			chunkData[x][z] = math.round(noiseValue * (15 * amplitude)) * blockSize.Y
		end
	end

	for x = 1, chunkSize do
		for z = 1, chunkSize do
			local chunkWorldPositionX = (chunkX - 1) * chunkSize * blockSize.X
			local chunkWorldPositionZ = (chunkZ - 1) * chunkSize * blockSize.Z

			local blockWorldPosition = Vector3.new(
				chunkWorldPositionX + (x - 1) * blockSize.X,
				chunkData[x][z],
				chunkWorldPositionZ + (z - 1) * blockSize.Z
			)

			if worldToBlockCoordinates(blockWorldPosition).Y > 0 then
				placeBlock(blocksFolder.Grass, blockWorldPosition, chunkFolder)
			else
				placeBlock(blocksFolder.Dirt, blockWorldPosition, chunkFolder)
			end
		end
	end

	loadedChunks["X: "..chunkX.." Z: "..chunkZ] = chunkFolder

	return chunkFolder
end

local function generateChunkRegion(startX: number, startZ: number, xChunks: number, zChunks: number)
	for x = 0, xChunks do
		for z = 0, zChunks do
			local chunkX = startX + x
			local chunkZ = startZ + z

			generateChunk(chunkX, chunkZ)
		end
	end
end

local function generateChunksAroundPlayer(player: Player)
	local character = player.Character
	if not character then return end

	local humanoidRootPart = character:FindFirstChild("HumanoidRootPart")
	if not humanoidRootPart then return end

	local playerPosition = humanoidRootPart.Position
	local playerChunkX = math.floor(playerPosition.X / (chunkSize * blockSize.X))
	local playerChunkZ = math.floor(playerPosition.Z / (chunkSize * blockSize.Z))

	generateChunkRegion(playerChunkX - serverRenderDistance, playerChunkZ - serverRenderDistance, serverRenderDistance * 2 + 1, serverRenderDistance * 2 + 1)
end

local function unloadChunksAroundPlayers()
	local occupiedChunks = {}

	-- Determine the chunks that are within the server render distance of any player
	for _, player in pairs(Players:GetPlayers()) do
		local character = player.Character
		if not character then continue end

		local humanoidRootPart = character:FindFirstChild("HumanoidRootPart")
		if not humanoidRootPart then continue end

		local playerPosition = humanoidRootPart.Position
		local chunkSizeX = chunkSize * blockSize.X
		local chunkSizeZ = chunkSize * blockSize.Z

		-- Calculate the player's chunk
		local playerChunkX = math.floor(playerPosition.X / chunkSizeX + 1)
		local playerChunkZ = math.floor(playerPosition.Z / chunkSizeZ + 1)

		-- Calculate min and max chunk coordinates
		local minChunkX = playerChunkX - serverRenderDistance - 1
		local maxChunkX = playerChunkX + serverRenderDistance + 1
		local minChunkZ = playerChunkZ - serverRenderDistance - 1
		local maxChunkZ = playerChunkZ + serverRenderDistance + 1

		-- Add occupied chunks to the table
		for x = minChunkX, maxChunkX do
			for z = minChunkZ, maxChunkZ do
				occupiedChunks["X: " .. x .. " Z: " .. z] = true
			end
		end
	end

	-- Unload chunks that are not occupied by any player
	for chunkKey, chunkFolder in pairs(loadedChunks) do
		if not occupiedChunks[chunkKey] then
			chunkFolder.Parent = nil
			loadedChunks[chunkKey] = nil
		end
	end
end


-- Runtime
Players.PlayerAdded:Connect(function(player)
	player.CharacterAdded:Connect(function(character)
		local humanoid = character:WaitForChild("Humanoid")
		-- Generate initial chunks
		generateChunksAroundPlayer(player)

		humanoid.Running:Connect(function()
			generateChunksAroundPlayer(player)
		end)
	end)
end)

task.spawn(function()
	while true do
		wait(5)
		for _, player in pairs(Players:GetPlayers()) do
			unloadChunksAroundPlayers()
		end
	end
end)

Usually, chunked terrain systems(like minecraft) run on the client instead of the server so that each client only renders their own render distance instead of all loaded chunks. The lag spikes are likely due to instancing many parts all at once on the server, so you can try making the chunks load over multiple frames

oh I see so would it be better to still create the chunks on the server but they are transparent instances with canCollide off and on the client I decide whether to render them or not? And to make the chunks load over multiple frames would I just use

game:GetService(“RunService”).Heartbeat:Wait()

it would be fine to make parts on the server for each individual chunk, but not each block, so that not as much network is used sending tons of parts to the client(regardless of if they are transparent or not)

If I create the block instances on the client I would need to tell the server to actually create it serverside so how Can I do that? The server would have to reconstruct the instance to my knowledge but I’m not sure

You don’t need to tell the server anything about generation, because every client will generate the same terrain if given the same randomseed
(The only reason I can think of why the server would need to have blocks instanced is for physics replication, but even then I think if both clients have the blocks loaded it might not matter)

1 Like

Ah you’re right thank you! Would you also recommend using parallel luau in this case?

I’ve made terrain generation in the past and adapted it to use parallel luau, it did increase the terrain generation speed, but you can only use it for the noise function part of the generation(you cannot instance parts in parallel), so I would only use it if your generation is super slow

I see, thanks for the help. How come I cant instance parts in parallel though? (im just starting to learn about parallel luau)

It’s a roblox limitation, you cant instance parts or change certain properties in parallel

Mm ok. One last thing also, currently to unload chunks im setting the chunk.Parent = nil instead of destroying it because its faster, but will this be a problem eventually? (I moved the terrain generation to client btw).

I don’t think it will be a problem because if there is no reference to an object in code and it’s parent is nil then it gets like fully destroyed, but I could be wrong

1 Like

Okay thank you for all the help :pray::pray:

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.