Chunk Loader / Unloader Module

Hello everybody, I recently made a chunk loader module (which happened to be quite unsuccessful causing more harm then good for framerate), and I would like you guys to help revise my coding practices and really just nitpick on lots of stuff I can improve on. (Note: This is my first ever attempt at writing a module of this complexity, and this is also my first ever post!)

Code:

local Settings = {
	RangeOfLoad = 500, -- Distance in which parts will load / deload
	RestrictedPaths = {game.Workspace.Restricted}, -- Folder / Instances you never want to be loaded / deloaded (Baseplate / required parts)
}

local ChunkLoader = {}

-- Tables
local PartData = {}
local PropertiesDATA = {}
PartData.__index = PartData


-- Services
local Camera = workspace.CurrentCamera
local CollectionService = game:GetService("CollectionService")
local RunService = game:GetService("RunService")

local function CheckProperty(InstanceObject, PropertyName)
	InstanceObject.Archivable = true
	local ObjectClone = InstanceObject:Clone()
	if ObjectClone == nil then return end
	ObjectClone:ClearAllChildren()

	return (pcall(function()
		return ObjectClone[PropertyName]
	end))
end

local function SetProperties(InstanceObject)
	local NewProperties = {}
	
	setmetatable(NewProperties, PartData)
	NewProperties.Object = InstanceObject
	if CheckProperty(InstanceObject, "Transparency") then
		NewProperties.Transparency = InstanceObject.Transparency
	end
	if CheckProperty(InstanceObject, "CanCollide") then
		NewProperties.CanCollide = InstanceObject.CanCollide
	end
	if CheckProperty(InstanceObject, "Anchored") then
		NewProperties.Anchored = InstanceObject.Anchored
	end
	
	return NewProperties
	
end

local function GetParentPart(Object)
	local FirstParent = Object.Parent
	if not FirstParent:IsA("BasePart") then
		return FirstParent.Parent
	else
		return FirstParent
	end
end

local function CheckRestrictions(InstanceObject)
	for i, v in pairs(Settings.RestrictedPaths) do
		if InstanceObject:IsDescendantOf(v) then
			return
		end
	end
	for i, v in pairs(game.Players:GetPlayers()) do
		local CheckCharacter = v.Character or v.CharacterAdded:Wait()
		if InstanceObject:IsDescendantOf(CheckCharacter) then 
			return
		end
	end
	return true
end

local function GetPropertiesDATA(InstanceObject, TargetProperty)
	for i, v in pairs(PropertiesDATA) do
		if v.Object == InstanceObject and v:GetProperties(TargetProperty) then
			return v:GetProperties(TargetProperty)
		end
	end
end

local function Render(Object, Bool)
	if not Bool and CheckRestrictions(Object) then
		CollectionService:AddTag(Object, "Unrendered")
		if Object:IsA("BasePart") then
			Object.Transparency = 1
			Object.CanCollide = false
			Object.Anchored = true
		elseif Object:IsA("Texture") or Object:IsA("Decal") then
			Object.Transparency = 1
		elseif Object:IsA("ParticleEmitter") then
			Object:Clear()
		end
	else
		CollectionService:RemoveTag(Object, "Unrendered")
		if Object:IsA("BasePart") and CheckRestrictions(Object) then
			if Object:IsA("BasePart") then
				local Transparency = GetPropertiesDATA(Object, "Transparency")
				local CanCollide = GetPropertiesDATA(Object, "CanCollide")
				local Anchored = GetPropertiesDATA(Object, "Anchored")
				Object.Transparency = Transparency
				Object.CanCollide = CanCollide
				Object.Anchored = Anchored
			elseif Object:IsA("Texture") or Object:IsA("Decal") then
				local Transparency = GetPropertiesDATA(Object, "Transparency")
				Object.Transparency = Transparency
			end
		end
	end
end

local CheckLogic = {
	Unrendered = function(HRP, BasePart, RenderObject)
		local _, CanSee = Camera:WorldToViewportPoint(BasePart.Position)
		if (HRP.Position - BasePart.Position).Magnitude <= Settings["RangeOfLoad"] and CanSee then
			Render(RenderObject, true)
		end
	end,
	Rendered = function(HRP, BasePart, RenderObject)
		local _, CanSee = Camera:WorldToViewportPoint(BasePart.Position)
		if (HRP.Position - BasePart.Position).Magnitude >= Settings["RangeOfLoad"] or not CanSee then
			Render(RenderObject, false)
		end
	end,
}

local function PartCheck(Character, InstanceObject)
	local HRP = Character:WaitForChild("HumanoidRootPart")
	if CollectionService:HasTag(InstanceObject, "Unrendered") then
		if InstanceObject:IsA("BasePart") then
			CheckLogic["Unrendered"](HRP, InstanceObject, InstanceObject)
		else
			local ParentPart = GetParentPart(InstanceObject)
			if ParentPart:IsA("BasePart") then
				CheckLogic["Unrendered"](HRP, ParentPart, InstanceObject)
			end
		end
	else
		if InstanceObject:IsA("BasePart") then
			CheckLogic["Rendered"](HRP, InstanceObject, InstanceObject)
		else
			local ParentPart = GetParentPart(InstanceObject)
			if ParentPart:IsA("BasePart") then
				CheckLogic["Rendered"](HRP, ParentPart, InstanceObject)
			end
		end
	end
end

local function CheckChunk(Character, TotalProperties)
	for i, v in pairs(workspace:GetDescendants()) do
		if not v:IsA("Terrain") and CheckRestrictions(v) then
			PartCheck(Character, v)
		end
	end
end

function ChunkLoader.Initialize(Player)
	local Character = Player.Character or Player.CharacterAdded:Wait()
	local Humanoid = Character:WaitForChild("Humanoid")
	
	for i, v in pairs(workspace:GetDescendants()) do
		if not v:IsA("Terrain") then
			local Properties = SetProperties(v)
			
			function Properties:GetProperties(PropertyName)
				return self[PropertyName]
			end
			
			table.insert(PropertiesDATA, Properties)
		end
	end
	
	local ChunkCheckConnection
	ChunkCheckConnection = RunService.Heartbeat:Connect(function()
		CheckChunk(Character)
	end)
	
	local ResetConnection
	ResetConnection = Humanoid.Died:Connect(function()
		ChunkCheckConnection:Disconnect()
		ResetConnection:Disconnect()
	end)
end

return ChunkLoader

Video of module:

4 Likes

Personally, I think that Streaming Enabled (Property of Workspace) would be much more efficient at loading chunks. Rather than creating a chunk loading module.

3 Likes

Ahh yea I saw that earlier but I was already almost done with this module, so I just wanted to finish this module and get feedback on my code. Thank you for letting me know though. :slight_smile:

3 Likes
RunService.Heartbeat:Connect(function()
	CheckChunk(Character)
end)
...
local function CheckChunk(Character, TotalProperties)
	for i, v in pairs(workspace:GetDescendants()) do
		if not v:IsA("Terrain") and CheckRestrictions(v) then
			PartCheck(Character, v)
		end
	end
end

Looping over every instance in Workspace every Heartbeat isn’t a great idea, and probably explains your performance issues. There’s a couple thing you can change to actually make your thing performant.

You should partition the world, so you can check if an entire partition of many instances has become visible or invisible at once, and so that you can load and unload all the instances in a partition at once. There’s two ways of partitioning that I’d recommend here.

  1. Partition the world into things that should and should not be checked for visibility / be loaded or unloaded dynamically. Your current approach just checks everything in Workspace, seemingly in an attempt to be as “automatic” as possible. I’d recommend making it less “automatic” and expect the user of the module to do some manual work to tell the module what should or shouldn’t be dynamically loaded, e.g. by tagging objects to be dynamically loaded.
  2. Spatially partition the world by dividing it up into chunks - i.e. make it an actual chunk loader. Chunks are a spatial partitioning scheme that divide a world into relatively large boxes so that logic can be performed on the entire block instead of everything inside the block individually, saving on computation.

Your current approach of checking things every Heartbeat is called “polling” - you’re constantly asking everything if it needs to be loaded or unloaded. It can be more efficient to use an “event-based” approach where these checks only happen when they need to - i.e. view frustum checks only happen when the camera has rotated or translated a certain amount, and distance-based checks only happen when the player has moved so and so far. That saves on unnecessarily checking again and again when you just checked less than a second ago.

The event-based distance thing plays nicely with spatial partitioning - if your world is partitioned into chunks you only need to make distance-based checks when the player moves from one chunk to another, cutting distance checks down from literally 60hz to once every few seconds, or even longer if the player isn’t moving a lot. If you also use a data structure that allows you to find all chunks in a certain radius without looping through every chunk in the entire game, the computational cost of distance checks suddenly becomes independent from how many things are in your game, and only depends on how many chunks are inside the render distance, which is constant and much lower than the 10s of 1000s of things that can be in a large game. Spatial partitioning also helps with view frustum checks - just check if the chunk is in view instead of every object inside the chunk.

Finally, I doubt view frustum checks are even going to be worth it. Roblox almost certainly has view frustum culling built in anyway, so I don’t think you’ll save anything in terms of rendering. I also don’t think making things invisible saves much on memory, but if you want to know for sure you’ll have to test it.

4 Likes

Much appreciated! I’ll definitely try turning this module more event-based. Thank you for the info and advice!

Yeah, in order to utilize GetDescendants properly, use an event to capture new descendants being added and add them to a table:

local descendants = workspace:GetDescendants()

workspace.DescendantAdded:Connect(function(descendant)
     table.insert(descendants, descendant)
end)

local function CheckChunk(Character, TotalProperties)
	for _, descendant in ipairs(descendants) do
		if not descendant:IsA("Terrain") and CheckRestrictions(descendant) then
			PartCheck(Character, descendant )
		end
	end
end

Disconnect the connection if done.

1 Like

Ahh never knew about :GetDescendants(), thanks for letting me know I’ll be sure to add that with my future coding projects if needed.

1 Like