Voxel Game Optimization

Hi, I’m currently making a voxel game with procedurally generated terrain.
However, i would like to know how to optimize it properly:

Chunks are currently generated and then placed inside a folder in workspace (which will replicate it to the client), and if the map size is too big, it will consume too much memory to the point where the Client can crash. And each chunk has an average of 3-7k+ voxels.
Screenshot_1141

StreamingEnabled could fix this, but having StreamingEnabled on causes a huge framerate drop when a chunk gets streamed into the Client.

And since chunks get replicated to the Client while they are being generated, it also causes framerate drops. (and also causes the recently created parts/voxels to be counted as moving/awake?)


However the performance after the chunks have finished generated is completly fine and stable.

And also since i’ve ever implemented SharedTables to save voxel positions to avoid them being placed over one another, there is usually missing voxels on the surface (and possibly underground). I also cant seem to place any new voxels on these positions.

ChunkHandler module

local replicatedStorage = game:GetService("ReplicatedStorage")
local scriptService = game:GetService("ServerScriptService")
local rs = game:GetService("RunService")
local modules = replicatedStorage:WaitForChild("Modules")

local blockHandler = require(modules.BlockHandler)

local worldFolder = workspace:WaitForChild("World")

local selfModule = {}

function selfModule:GetChunks()
	local chunks = {}
	for i,v in pairs(worldFolder:GetChildren()) do
		if v:IsA("Folder") then
			table.insert(chunks, v)
		end
	end
	return chunks
end

function selfModule:GetBlocks(chunkFolder: Folder)
	local blocks = {}
	for i,v in pairs(chunkFolder:GetDescendants()) do
		if v:IsA("BasePart") then
			table.insert(blocks, v)
		end
	end
	return blocks
end


selfModule.newChunk = function(chunkX, chunkZ, chunkSize, SEED, HILL_SCALE, BLOCKSIZE, numDirtLayers, numStoneLayers, numCaveStoneLayers, numDeeprockLayers, CAVE_SCALE)

	replicatedStorage.Values.ChunksInGeneration.Value += 1

	local chunkFolder = Instance.new("Folder")
	chunkFolder.Name = "Chunk_"..chunkX.."_"..chunkZ
	chunkFolder.Parent = worldFolder
	for x = 1, chunkSize do
		for z = 1, chunkSize do
			rs.Heartbeat:Wait()

			local realX = (chunkX - 1)*chunkSize + x
			local realZ = (chunkZ - 1)*chunkSize + z

			local noise = math.noise(SEED, realX/12,realZ/12)*HILL_SCALE

			local position = Vector3.new(realX * BLOCKSIZE, math.floor(noise)*BLOCKSIZE, realZ * BLOCKSIZE)
			local grassBlock = blockHandler.newBlock(CFrame.new(position), chunkFolder, "Grass")
			rs.Heartbeat:Wait()
			
			local treeNoise = math.noise(SEED, realX/12,realZ/12)*(HILL_SCALE/2)
			
			if treeNoise >= 0.975 then
				task.spawn(blockHandler.newTree, position + Vector3.new(0,3,0), SEED, chunkFolder)
			end

			task.spawn(function()
				for i = 1, numDirtLayers do
					local dirtPosition = Vector3.new(
						realX * BLOCKSIZE,
						math.floor(noise)*BLOCKSIZE - (BLOCKSIZE * i),
						realZ * BLOCKSIZE
					)
					local dirtBlock = blockHandler.newBlock(CFrame.new(dirtPosition), chunkFolder, "Dirt")
					rs.Heartbeat:Wait()
				end
			end)

			task.spawn(function()
				for i = 1, numStoneLayers do
					local stonePosition = Vector3.new(
						realX * BLOCKSIZE,
						math.floor(noise)*BLOCKSIZE - (BLOCKSIZE * i) - ( (numDirtLayers) * BLOCKSIZE),
						realZ * BLOCKSIZE
					)
					local stoneBlock = blockHandler.newBlock(CFrame.new(stonePosition), chunkFolder, "Stone")
					rs.Heartbeat:Wait()
				end
			end)

			task.spawn(function()
				for i = 1, numCaveStoneLayers do
					-- Use math.noise to simulate a perlin worm to create caves
					local caveNoise = math.noise(realX/12, realZ/12, i/12) * CAVE_SCALE
					if caveNoise > 0 then
						local stonePosition = Vector3.new(
							realX * BLOCKSIZE,
							math.floor(noise)*BLOCKSIZE - (BLOCKSIZE * i) - ( (numDirtLayers + numStoneLayers) * BLOCKSIZE),
							realZ * BLOCKSIZE
						)
						local stoneBlock = blockHandler.newBlock(CFrame.new(stonePosition), chunkFolder, "Stone")
						rs.Heartbeat:Wait()
					end
				end
			end)

			task.spawn(function()
				for i = 1, numStoneLayers do
					local stonePosition = Vector3.new(
						realX * BLOCKSIZE,
						math.floor(noise)*BLOCKSIZE - (BLOCKSIZE * i) - ( (numDirtLayers + numStoneLayers + numCaveStoneLayers) * BLOCKSIZE),
						realZ * BLOCKSIZE
					)
					local stoneBlock = blockHandler.newBlock(CFrame.new(stonePosition), chunkFolder, "Stone")
					rs.Heartbeat:Wait()
				end
			end)

			task.spawn(function()
				for i = 1, numDeeprockLayers do
					local deepRockPosition = Vector3.new(
						realX * BLOCKSIZE,
						math.floor(noise)*BLOCKSIZE - (BLOCKSIZE * i) - ((numDirtLayers + (numStoneLayers * 2) + numCaveStoneLayers) * BLOCKSIZE),
						realZ * BLOCKSIZE
					)
					local deepRockBlock = blockHandler.newBlock(CFrame.new(deepRockPosition), chunkFolder, "Deeprock")
					rs.Heartbeat:Wait()
				end
			end)
		end
	end
	print("-")
	print("Generated chunk: ".. chunkFolder.Name)
	print("Blocks in chunk: ".. #selfModule:GetBlocks(chunkFolder))

	replicatedStorage.Values.ChunksInGeneration.Value -= 1
	replicatedStorage.Values.ChunksGenerated.Value += 1

	return chunkFolder
end

return selfModule

BlockHandler module


local replicatedStorage = game:GetService("ReplicatedStorage")
local sharedTableService = game:GetService("SharedTableRegistry")

local models = replicatedStorage:WaitForChild("Models")

local blockModel = models.Block
local module = {}

task.spawn(function()
	if sharedTableService:GetSharedTable("TreePositions") == nil then
		local treePositions = SharedTable.new()
		sharedTableService:SetSharedTable("TreePositions", treePositions)
	end
	
	if sharedTableService:GetSharedTable("BlockPositions") == nil then
		local blockPositions = SharedTable.new()
		sharedTableService:SetSharedTable("BlockPositions", blockPositions)
	end
end)

local function copyArray(s: SharedTable)
	local t = {}
	for key, val in s do
		if typeof(val) == "SharedTable" then
			t[key] = copyArray(val)
		else
			t[key] = val
		end
	end
	return t
end

local blocks = {
	["Grass"] = {
		["Destroyable"] = true,
		["DestroyTime"] = 1,
		["Color"] = BrickColor.new("Bright green"),
		["Material"] = Enum.Material.Plastic,
		["Surfaces"] = {
			["Top"] = Enum.SurfaceType.Studs,
			["Front"] = Enum.SurfaceType.Studs,
			["Right"] = Enum.SurfaceType.Studs,
			["Left"] = Enum.SurfaceType.Studs,
			["Back"] = Enum.SurfaceType.Studs,
			["Bottom"] = Enum.SurfaceType.Inlet
		},
		["CanCollide"] = true,
		["CastShadows"] = true,
	},
	["Dirt"] = {
		["Destroyable"] = true,
		["DestroyTime"] = 1,
		["Color"] = BrickColor.new("Brown"),
		["Material"] = Enum.Material.Plastic,
		["Surfaces"] = {
			["Top"] = Enum.SurfaceType.Universal,
			["Front"] = Enum.SurfaceType.Universal,
			["Right"] = Enum.SurfaceType.Universal,
			["Left"] = Enum.SurfaceType.Universal,
			["Back"] = Enum.SurfaceType.Universal,
			["Bottom"] = Enum.SurfaceType.Universal
		},
		["CanCollide"] = true,
		["CastShadows"] = true,
	},
	["Stone"] = {
		["Destroyable"] = true,
		["DestroyTime"] = 6,
		["Color"] = BrickColor.new("Medium stone grey"),
		["Material"] = Enum.Material.Plastic,
		["Surfaces"] = {
			["Top"] = Enum.SurfaceType.Weld,
			["Front"] = Enum.SurfaceType.Weld,
			["Right"] = Enum.SurfaceType.Weld,
			["Left"] = Enum.SurfaceType.Weld,
			["Back"] = Enum.SurfaceType.Weld,
			["Bottom"] = Enum.SurfaceType.Weld
		},
		["CanCollide"] = true,
		["CastShadows"] = true,
	},
	["Deeprock"] = {
		["Destroyable"] = false,
		["DestroyTime"] = 1000,
		["Color"] = BrickColor.new("Black"),
		["Material"] = Enum.Material.Plastic,
		["Surfaces"] = {
			["Top"] = Enum.SurfaceType.Smooth,
			["Front"] = Enum.SurfaceType.Smooth,
			["Right"] = Enum.SurfaceType.Smooth,
			["Left"] = Enum.SurfaceType.Smooth,
			["Back"] = Enum.SurfaceType.Smooth,
			["Bottom"] = Enum.SurfaceType.Smooth
		},
		["CanCollide"] = true,
		["CastShadows"] = false,
	},
	["Wool"] = {
		["Destroyable"] = true,
		["DestroyTime"] = 0.5,
		["Color"] = BrickColor.new("Bright red"),
		["Material"] = Enum.Material.Plastic,
		["Surfaces"] = {
			["Top"] = Enum.SurfaceType.Weld,
			["Front"] = Enum.SurfaceType.Weld,
			["Right"] = Enum.SurfaceType.Weld,
			["Left"] = Enum.SurfaceType.Weld,
			["Back"] = Enum.SurfaceType.Weld,
			["Bottom"] = Enum.SurfaceType.Weld
		},
		["CanCollide"] = true,
		["CastShadows"] = true,
	},
	["TreeLeaves"] = {
		["Destroyable"] = true,
		["DestroyTime"] = 0.5,
		["Color"] = BrickColor.new("Dark green"),
		["Material"] = Enum.Material.Plastic,
		["Surfaces"] = {
			["Top"] = Enum.SurfaceType.Weld,
			["Front"] = Enum.SurfaceType.Weld,
			["Right"] = Enum.SurfaceType.Weld,
			["Left"] = Enum.SurfaceType.Weld,
			["Back"] = Enum.SurfaceType.Weld,
			["Bottom"] = Enum.SurfaceType.Weld
		},
		["CanCollide"] = true,
		["CastShadows"] = false,
	},
	["TreeLog"] = {
		["Destroyable"] = true,
		["DestroyTime"] = 3.5,
		["Color"] = BrickColor.new("Medium brown"),
		["Material"] = Enum.Material.Plastic,
		["Surfaces"] = {
			["Top"] = Enum.SurfaceType.Studs,
			["Front"] = Enum.SurfaceType.Smooth,
			["Right"] = Enum.SurfaceType.Smooth,
			["Left"] = Enum.SurfaceType.Smooth,
			["Back"] = Enum.SurfaceType.Smooth,
			["Bottom"] = Enum.SurfaceType.Inlet
		},
		["CanCollide"] = true,
		["CastShadows"] = true,
	}
}

module.newBlock = function(blockPosition: CFrame, parent: Folder, blockName: string): BasePart
	task.spawn(function()
		local getPositions = sharedTableService:GetSharedTable("BlockPositions")
		local vc = blockPosition.Position
		if module:IsBlockPositionAvailable(vc) then

			local newBlock = blockModel:Clone()
			newBlock.Parent = parent or workspace:FindFirstChild("World")
			getPositions[vc.X .. vc.Y .. vc.Z] = true
			sharedTableService:SetSharedTable("BlockPositions", getPositions)
			newBlock:PivotTo(blockPosition)
			newBlock.CanTouch = false

			newBlock:SetAttribute("Destroyable", true)
			newBlock:SetAttribute("DestroyTime", 1)

			if blockName and blocks[blockName] then
				newBlock:SetAttribute("Destroyable", blocks[blockName].Destroyable)
				newBlock:SetAttribute("DestroyTime", blocks[blockName].DestroyTime)
				newBlock.BrickColor = blocks[blockName].Color
				newBlock.Material = blocks[blockName].Material

				newBlock.TopSurface = blocks[blockName].Surfaces.Top
				newBlock.FrontSurface = blocks[blockName].Surfaces.Front
				newBlock.RightSurface = blocks[blockName].Surfaces.Right
				newBlock.LeftSurface = blocks[blockName].Surfaces.Left
				newBlock.BackSurface = blocks[blockName].Surfaces.Back
				newBlock.BottomSurface = blocks[blockName].Surfaces.Bottom

				newBlock.CanCollide = blocks[blockName].CanCollide
				newBlock.CastShadow = blocks[blockName].CastShadows
			end

			local connection
			connection = newBlock.AttributeChanged:Connect(function(attribute)
				if attribute == "DestroyProgress" and newBlock:GetAttribute("Destroyable") == true then
					local decals = {}
					for i,v in pairs(newBlock:GetChildren()) do
						if v.Name == "DestroyProgress_Decal" then
							table.insert(decals, v)
						end
					end
					local progress = newBlock:GetAttribute("DestroyProgress")
					local transparency = (1 - ( progress / newBlock:GetAttribute("DestroyTime") ) )
					for i,v in pairs(decals) do
						v.Transparency = transparency
					end

					if progress >= newBlock:GetAttribute("DestroyTime") then
						connection:Disconnect()
						local getPositions = sharedTableService:GetSharedTable("BlockPositions")
						local vc = blockPosition.Position
						getPositions[vc.X .. vc.Y .. vc.Z] = nil
						sharedTableService:SetSharedTable("BlockPositions", getPositions)

						newBlock:Destroy()
					end
				end
			end)
			return newBlock
		end
		return nil
	end)
end

module.newTree = function(treeStart: Vector3, worldSeed: number, parent: Folder)
	local getTreePositions = sharedTableService:GetSharedTable("TreePositions")
	
	for i,v in pairs(copyArray(getTreePositions)) do
		local separatedString = string.split(i,",")
		local getVector = Vector3.new(tonumber(separatedString[1]), tonumber(separatedString[2]), tonumber(separatedString[3]))
		local distance = (treeStart - getVector).Magnitude
		if distance <= (3*4) then
			return
		end
	end
	
	local treeFolder = Instance.new("Folder")
	treeFolder.Name = "Tree"
	treeFolder.Parent = parent
	local baseBlock = module.newBlock(CFrame.new(treeStart), treeFolder, "TreeLog")
	getTreePositions[treeStart.X..","..treeStart.Y..","..treeStart.Z] = true
	sharedTableService:SetSharedTable("TreePositions", getTreePositions)
	local amountOfLogs = math.noise(worldSeed, treeStart.Z/8, treeStart.X/8)
	amountOfLogs = math.clamp(amountOfLogs, 3, 6)
	for i= 1, amountOfLogs do
		local logPosition: Vector3 = treeStart + Vector3.new(0,i*3,0)
		module.newBlock(CFrame.new(logPosition), treeFolder, "TreeLog")

		if i == amountOfLogs or i == amountOfLogs -1 then
			if i == amountOfLogs-1 then
				local leafPositions = {
					Vector3.new(3,0,0),
					Vector3.new(-3,0,0),
					Vector3.new(3,0,3),
					Vector3.new(-3,0,-3),
					Vector3.new(3,0,-3),
					Vector3.new(-3,0,3),
					Vector3.new(6,0,0),
					Vector3.new(0,0,6),
					Vector3.new(-6,0,0),
					Vector3.new(0,0,-6),
					Vector3.new(6,0,-6),
					Vector3.new(-6,0,-6),

					Vector3.new(3,3,0),
					Vector3.new(-3,3,0),
					Vector3.new(0,3,3),
					Vector3.new(0,3,-3)
				}
				for i,v in pairs(leafPositions) do
					module.newBlock(CFrame.new(logPosition + v), treeFolder, "TreeLeaves")
				end
			elseif i == amountOfLogs then
				local leafPositions = {
					Vector3.new(3,0,0),
					Vector3.new(-3,0,0),
					Vector3.new(3,0,3),
					Vector3.new(-3,0,-3),
					Vector3.new(3,0,-3),
					Vector3.new(-3,0,3),
					Vector3.new(0,3,0),
					Vector3.new(6,0,0),
					Vector3.new(0,0,6),
					Vector3.new(-6,0,0),
					Vector3.new(0,0,-6),
					Vector3.new(6,0,-6),
					Vector3.new(-6,0,-6),

					Vector3.new(3,3,0),
					Vector3.new(-3,3,0),
					Vector3.new(0,3,3),
					Vector3.new(0,3,-3)
				}
				for i,v in pairs(leafPositions) do
					module.newBlock(CFrame.new(logPosition + v), treeFolder, "TreeLeaves")
				end
			end
		end
	end
end

function module:IsBlockPositionAvailable(pos: Vector3): boolean
	local getPositions = sharedTableService:GetSharedTable("BlockPositions")
	if getPositions[pos.X .. pos.Y .. pos.Z] then
		return false
	end
	return true
end

function module:DestroyBlock(block: BasePart)
	if block:GetAttribute("Destroyable") == true then
		block:Destroy()
	end
end
return module

WorldGeneration script

-- World generation script

local replicatedStorage = game:GetService("ReplicatedStorage")
local modules = replicatedStorage.Modules
local rs = game:GetService("RunService")

local worldFolder = Instance.new("Folder")
worldFolder.Name = "World"
worldFolder.Parent = workspace

local blockHandler = require(modules.BlockHandler)
local chunkHandler = require(modules.ChunkHandler)

local MAPSIZE = 35
local MAPSCALE = MAPSIZE/2
local BLOCKSIZE = 3

local HILL_SCALE = 7
local CAVE_SCALE = 5.5

local SEED = math.random(-10000000,10000000)

local chunkSize = 16
local numChunks = math.ceil(MAPSIZE / chunkSize)

local numDirtLayers = 2
local numStoneLayers = 5
local numCaveStoneLayers = 26
local numDeeprockLayers = 2

local chunksInGeneration = replicatedStorage.Values.ChunksInGeneration
local chunksGenerated = replicatedStorage.Values.ChunksGenerated
local chunksToGenerate = replicatedStorage.Values.ChunksToGenerate
local chunksLimit = 6

local function getBlocks(chunkFolder: Folder)
	local blocks = {}
	for i,v in pairs(chunkFolder:GetDescendants()) do
		if v:IsA("BasePart") then
			table.insert(blocks, v)
		end
	end
	return blocks
end

local function getChunks()
	local chunks = {}
	for i,v in pairs(worldFolder:GetChildren()) do
		if v:IsA("Folder") then
			table.insert(chunks, v)
		end
	end
	return chunks
end

function generateChunk(chunkX, chunkZ)
	task.spawn(function()
		if chunksInGeneration.Value >= chunksLimit then
			repeat task.wait(2) until chunksInGeneration.Value < chunksLimit
		end
		task.spawn(chunkHandler.newChunk, chunkX, chunkZ, chunkSize, SEED, HILL_SCALE, BLOCKSIZE, numDirtLayers, numStoneLayers, numCaveStoneLayers, numDeeprockLayers, CAVE_SCALE)
	end)
end

function beginWorldGeneration()
	task.spawn(function()
		for chunkX = 0, numChunks do
			for chunkZ = 0, numChunks do
				generateChunk(chunkX, chunkZ)

				if chunkX > 0 and chunkZ > 0 then
					chunksToGenerate.Value += 1
					generateChunk(-chunkX, -chunkZ)
				end

				if chunkX > 0 then
					chunksToGenerate.Value += 1
					generateChunk(-chunkX, chunkZ)
				end

				if chunkZ > 0 then
					chunksToGenerate.Value += 1
					generateChunk(chunkX, -chunkZ)
				end
			end
		end
	end)

	if chunksGenerated.Value < chunksToGenerate.Value then
		repeat task.wait(4) until chunksGenerated.Value >= chunksToGenerate.Value
	end

	print("-")
	print("World generated")
	print("Chunks: ".. #getChunks())
	print("Blocks: ".. #getBlocks(worldFolder))
	print("Seed: ".. SEED)
	game.Players.CharacterAutoLoads = true
	
	for i,v in pairs(game.Players:GetPlayers()) do
		v:LoadCharacter()
	end
end

replicatedStorage.Events.WorldGeneration.OnServerEvent:Connect(function(player, dataTable)
	
	SEED = dataTable["Seed"] or math.random(-10000000,10000000)
	MAPSIZE = math.clamp(dataTable["WorldSize"] or 35, 10, 200) or 35
	HILL_SCALE = math.clamp(dataTable["HillScale"] or 7,1,35) or 7
	CAVE_SCALE = math.clamp(dataTable["CaveScale"] or 5.5,1, 20) or 5.5
	numDirtLayers = math.clamp(dataTable["DirtAmount"] or 2,1, 40) or 2
	numStoneLayers = math.clamp(dataTable["StoneAmount"] or 5,1, 40) or 5
	numCaveStoneLayers = math.clamp(dataTable["CaveStoneAmount"] or 26,1,40) or 26
	numDeeprockLayers = math.clamp(dataTable["DeeprockAmount"] or 2,1,40) or 2
	chunksLimit = math.clamp(dataTable["ChunkLimit"] or 6,1,12) or 6
	MAPSCALE = MAPSIZE/2
	numChunks = math.ceil(MAPSIZE / chunkSize)
	
	chunksToGenerate.Value = numChunks
	
	beginWorldGeneration()
end)

You can try out the game by yourself here.

I would also like to clarify that im mostly a begineer in terms of scripting.

Any help and feedback is appreciated, thanks!

1 Like

streamingenabled is a terrible idea because youre already loading chunks with your own code

i think alot of your voxels are coming from the leaves, so you should make the leaves manually and then export these parts into a mesh so they can render super fast as one object. Then in the script you can clone the mesh instead of all of the leaf parts

you can also turn off CanCollide CanQuery and CanTouch for parts that wont be interacting with the character

also task.spawn() is when you want to create a new “thread”, but this thread only allows you to do things simultaneously in the one actual thread, so it’s not going to make the computing faster

you can look at parallel lua for multithreading that allows you to use more of the cpu’s capabilities. they also have a terrain generation example

edit: Use your own chunk unloading instead of streaming enabled. Also use object or part cache because creating/destroying parts makes lag over time (idk if fixed)

2 Likes
  1. Ever since i noticed the framerate drops with StreamingEnabled, i disabled it.

  2. I dont think most of the voxels are coming from the leaves of trees, only around 10 leaf voxels get generated per tree, compared to the actual terrain it’s a huge difference.

  3. I already did this for some of the voxels.

  4. Yeah i know it actually doesn’t make another thread, but it still allows the script to generate more than 1 chunk at a time, rather than it having to wait for the previous one to finish to start generating a new one.

  5. I’ve tried to use Parallel Lua for this, but i simply couldn’t get it to work so i scrapped it. (chunks just wouldn’t generate any blocks)

1 Like

There are a lot of way you can go about onto optimizing your code. The main issue your having right now is is having too many Parts, since the parts are being made on the server, StreamingEnabled can help, but is only a bandaid fix.

One simple way into optimizing this is to implement culling. Culling basically just checks each blocks surrounding neighbors, if each side is covered, just don’t generate the block.

Another more complicated method is called Greedy Meshing. (This guy explains it super well.)

1 Like

Not going to use StreamingEnabled after the framerate drops i experienced when chunks were getting streamed into the client.

I think roblox’s default occlusion culling does the job pretty well as i was getting around 30-50 FPS. Memory on the other hand, it’s gonna be a bit difficult.

I would rather not use greedy meshing because of the way my block placing & destroying system works. I also dont want to remesh the whole chunk on a block update, sounds very tedious for me.

Also, i’ve been experiencing another issue.

With a map size of 10, the script should generate 9 chunks.
But the number value gets set to 6, and it messes up the client loading as the client will see chunks getting generated upon spawning.

I do not know why this happens.

EDIT: Nevermind, this one is fixed.

Using culling or any other system to lower part count will decrease memory usage. Roblox’s occlusion culling yes will help in rendering, it doesn’t help in memory usage, bandwidth, and part count. Only other way I can think of optimizing a server ran rendering system is using parallel lua, which was stated by another guy above. Multi-threading, however, will only speed up your generation time and not lower memory usage.

I would still 100% recommend reworking your scripts into supporting culling.

Edit: a quick little optimization I saw was in ChunkHandler newChunk function, move:

ChunkFolder.Parent = worldFolder

Below the for loop. I don’t know exactly why, maybe Roblox doesn’t have to update rendering in workspace, but it does improve performance.