Optimizing chunked terrain made of parts

I’ve been trying to implement a part-based terrain system for my game, where i generate terrain in chunks and vertical slices using greedy meshing to combine them into the least parts possible, and having the terrain modifiable with a shovel which adds a block (3x3x3, each chunk is 11x1x11 blocks)

The issue I’m having is generating terrain is very laggy and I don’t know how to optimize it to run faster, I have read about using parallel luau to split loading but I don’t know how to implement this properly. I currently use a lot of task.spawn() but I want the terrain generation to happen faster without lag rather than just without lag

I tried splitting the terrain generator into 4-8 actors but this didn’t help much, and then I tried making the code that reads/writes to the table I store the chunk data on to work on actors but it broke everything

I currently store my terrain as one table with a key representing the chunk position and a 16 byte buffer (makes serialization more straightforward for saving) to represent whether each x,z position in the chunk has a tile or not, then I use a greedy mesher to determine when to make a part. Whenever the terrain is modified I call a function which determines the size of the new terrain part

This is AddTilesAt, the one that handles the terrain table content

local function AddTilesAt(position: Vector3, size: Vector3, value: number, dont_regenerate: boolean?)
	local size: Vector3 = size or Vector3.one
	size = Vector3.new(
		math.round(math.max(size.X, 1)),
		math.round(math.max(size.Y, 1)),
		math.round(math.max(size.Z, 1))
	)

	local time_ = os.clock()
	local affectedchunks = {}
	for ax=position.X,position.X + size.X - 1 do
		for az=position.Z,position.Z + size.Z - 1 do
			for ay=position.Y,position.Y + size.Y - 1 do
				local xp, yp, zp = ax % 11, ay, az % 11
				local _chunk = Vector3.new(
					math.floor(ax/11),
					math.floor(ay),
					math.floor(az/11)
				)
				local chunk = string.format("%i,%i,%i",
					_chunk.X,
					_chunk.Y,
					_chunk.Z
				)
				if tiles[chunk] == nil then tiles[chunk] = buffer.create(16) end
				buffer.writebits(tiles[chunk], xp*11+zp, 1, value)
				affectedchunks[chunk] = _chunk
			end
		end
	end
	if not dont_regenerate then
		task.spawn(function()
			for _,v in pairs(affectedchunks) do
				generatechunk(v)
				task.wait()
			end
		end)
	end

	return affectedchunks
end

This is generatechunk, which generates each chunk (11x1x11) based off of the contents of the table

Actor:BindToMessageParallel("Generate", function(data: buffer?, position: Vector3)
	if not data then return end
	task.synchronize()
	local pos = string.format("%i,%i,%i", position.X, position.Y, position.Z)
	task.spawn(function()
		for _,v in pairs(CollectionService:GetTagged("Chunk"..pos)) do
			v:Destroy()
		end
	end)
	task.desynchronize()
	local partdataresult = {}
	local lvx, lvz = 0, 0
	while lvx ~= 11 do
		if lvz >= 11 then lvx += 1; lvz = 0 end
		if lvx >= 11 then break end
		if buffer.readbits(data, lvx * 11, 11) == 0 then lvx += 1; lvz = 0; continue end
		local lptr = lvx * 11 + lvz
		local type_ = buffer.readbits(data, lptr, 1)
		local npz = lvz
		for p=lvz,11 do
			npz = p
			if p == 11 then break end
			if buffer.readbits(data, lvx*11 + npz, 1) ~= type_ then break end
		end
		npz = npz - 1
		local npx = lvx
		buffer.writebits(data, lvx*11 + lvz, npz-lvz+1, 0)
		if type_ == 1 then
			local function cango()
				if (npx+1)*11 + (npz-lvz) >= 121 then return false end
				return buffer.readbits(data, (npx+1) * 11 + lvz, npz-lvz+1) == (math.pow(2, npz+1)-1)
			end
			while cango() do
				npx += 1
			end
			for i=lvx+1, npx do
				buffer.writebits(data, i*11 + lvz, npz-lvz+1, 0)
			end
			--local npart = Instance.new("Part")
			local npart = {}
			npart.Size = Vector3.new(npx-lvx+1, 1, npz-lvz+1) * TileSize
			npart.ChunkStart = string.format(
				"%i,%i,%i",
				position.X*11+lvx-1,
				position.Y,
				position.Z*11+lvz-1
			)
			npart.ChunkSize = string.format(
				"%i,%i",
				npx-lvx+1,
				npz-lvz+1
			)
			npart.Position = (Vector3.new(
				position.X*11+lvx,
				position.Y+0.5,
				position.Z*11+lvz
				) * TileSize) + npart.Size/2 - Vector3.new(TileSize.X/2, TileSize.Y, TileSize.Z/2)
			npart.ChunkPosition = position
			table.insert(partdataresult, npart)
		end
		lvz += 1
	end
	data = nil
	task.synchronize()
	for _,v in ipairs(partdataresult) do
		local npart = Instance.new("Part")
		npart.Size = v.Size
		npart.Position = v.Position
		npart.Anchored = true
		npart.Name = v.ChunkPosition.Y
		npart:SetAttribute("Size", v.ChunkSize)
		npart:SetAttribute("Start", v.ChunkStart)
		npart:AddTag(string.format("Chunk%i,%i,%i", v.ChunkPosition.X, v.ChunkPosition.Y, v.ChunkPosition.Z))
		npart.Parent = workspace.TerrainBlocks
		npart:MakeJoints()
		ServerStorage.Events.SetUpTerrain:Fire(npart, position.Y)
		RunService.PreSimulation:Wait()
	end
end)

The lag is caused by instances being made and deleted. That gives you the idea of what to fix.

What you can do is, rather than destroying parts not being rendered, you can reuse them to make the parts that are going to be rendered. This way there will be way less destruction and creation, and just changing a few variables, which would make it faster.

Alternatively, a much better way is to use editable meshes to create chunks as it will leave out most of the triangles that will never be rendered.

Hope this helped!

TL;DR:

Don’t create and destroy parts, change them. Use editable meshes for better performance too.

I don’t think the first approach would be viable unless I was to keep a cache of “available parts” somewhere, which I tried to pull off and failed from actors picking the same part and reusing it
And I currently can’t use editable meshes as my account isn’t age verified yet

Would it be possible for you to share some images of the terrain? If available, a short video would also be incredibly helpful for us to better visualize and understand what you’re aiming to achieve.


You can see terrain refreshing whenever you dig, and doing ;fixterrain causes a lagspike
This, and terrain being slow generating when loading the map (usually because of large terrain sizes)

I want to see if there’s a faster way to generate terrain than this, as my only other solution is removing task.wait() which makes the lag spike worse but finishes faster

ok so the reason why it feels slow is because you have

RunService.PreSimulation:Wait()

but if you remove that then I assume your game stutters when updating

so we must find a way to remove it but make the generation fast enough so it can finish within 1 frame

one thing you can do is have dynamic waiting like this

local clock = os.clock()
local time = 0
for _,v in ipairs(partdataresult) do
		local npart = Instance.new("Part")
		npart.Size = v.Size
		npart.Position = v.Position
		npart.Anchored = true
		npart.Name = v.ChunkPosition.Y
		npart:SetAttribute("Size", v.ChunkSize)
		npart:SetAttribute("Start", v.ChunkStart)
		npart:AddTag(string.format("Chunk%i,%i,%i", v.ChunkPosition.X, v.ChunkPosition.Y, v.ChunkPosition.Z))
		npart.Parent = workspace.TerrainBlocks
		npart:MakeJoints()
		ServerStorage.Events.SetUpTerrain:Fire(npart, position.Y)
		time += os.clock() - clock
		if time > 0.008 then time = 0 task.wait() end
		clock = os.clock()
	end

what this does is only waits if it takes longer then 0.008 seconds to load the parts

but this is not a good option because the way the terrain is generating is not as optimal as it could be this is one of the main problems with greedy mesh it makes things harder to update this is why many games don’t use greedy mesh but only use culling

but if you want to keep greedy mesh you need do it in a way that does not require you to update many parts of the chunk when watching the video i see parts updating that don’t need to be updated

1 Like

You have dedicated libraries for that.

It’s best to use part caching as it would prevent 90% of the lag spikes at most. Lag spikes will still be caused the first time you test the game but for everything else, I doubt it.

You could, however, use editable meshes. They are extremely performant and won’t cause lag at the start and in the future and are easy to implement as well.

1 Like

this is chatgpt, but i am curious if any of it seems like it could be valid? I read through it and the later points were somewhat intuitive.

CHATGPT:

  1. Use a PartCache System for Greedy Mesh Parts

    Why: Instance.new(“Part”) and constant destruction/recreation during digging cause massive GC pressure and performance drops.
    Fix: Reuse parts from a cache (e.g., ForeverHD’s PartCache).

:hammer_and_wrench: Example:
Instead of creating new parts in generatechunk, pull from a preallocated part pool:

local cachedPart = PartCache:GetPart()
cachedPart.Size = …
cachedPart.Position = …
cachedPart.Parent = workspace.TerrainBlocks

:no_entry_sign: 2. Avoid Unnecessary Regenerations

You're calling generatechunk() for every affected chunk—even if only a single tile changed.
Optimize by checking if the tile state actually changed before triggering regen.

if oldValue ~= value then
affectedchunks[chunk] = _chunk
end

This prevents full regen on no-op shovel hits or edge-case edits.
:brain: 3. Use Compressed Bitfields Smarter

Your buffer.writebits is smart, but:

You're writi
  1. Use Compressed Bitfields Smarter

Your buffer.writebits is smart, but:

You're writing bits for every single tile mod.

Writing full XZ bitfields (buffer.readbits(data, lvx * 11, 11)) every iteration can be slower than necessary.

:point_right: Consider caching a “dirty” flag per chunk. Only re-read full bitfields when dirty, and reset the flag after mesh is updated.
:zap: 4. Dynamic Batching in Meshing

Right now, meshing is done per 1x11x11 chunk, but you can batch multiple adjacent chunks into a superchunk (e.g. 2x2x1 chunks = 22x22x1 blocks).

:white_check_mark: Benefits:

Fewer actor calls

Fewer overall parts

Better greedy merge potential

Downside: harder to manage edits (so cache previous part bounds for better diffing).
:arrows_counterclockwise: 5. Pre-bake Common Meshes

If terrain is mostly pre-set (like map-loading), consider pre-generating commonly used part sizes (e.g. 3x3x1, 2x4x1) and clone them instead of recalculating/meshing every load.
:thread: 6. Parallelize with Actor:SendToMessageParallel Smarter

You’re trying to use parallelism but possibly misusing data sync. Here’s a safer pattern:

– Main thread
local bufferClone = buffer.clone(data)
actor:SendToMessageParallel(“Generate”, bufferClone, position)

Inside actor:

Actor:BindToMessageParallel(“Generate”, function(bufferData, pos)
local result = generateGreedyMesh(bufferData)
task.synchronize() – safely create parts
createParts(result)
end)

Avoid writing shared memory (like tiles) from parallel context—sync first.
:brick: 7. Consider Switch to Per-Tile Meshes + Frustum Culling

Alternative strategy: Use simple per-tile parts (or quads), and only render what’s near the player.
Greedy meshing is great, but updating it in real-time sucks. Minecraft avoids this with frustum + distance culling.

The part of my code which generates the parts themselves is split into actors (one per generation task, which is at most ~10 actors since now I batch terrain generation instead of making a new thread for every chunk), and when I tried to implement the PartCache module I ran into the issue of having to deal with two actors using the same part and causing bugs

With culling do you refer to EditableMeshes or am I missing something? I use greedy meshing because I thought that would lessen the lag from having to make 121 new parts for every chunk

culling simply does not generate blocks that are not touching air this can be done with EditableMeshes or with parts does not really mater

1 Like