ChunkLoader Module To Improve Game Render Performance

Post has been edited with your design! I really appreciate it. I also learned a bit from how you designed it :V Thank you!

Thank you! Looks great by the way.

We can work together to make the occlusion culling. I have some knowledge on that.

If you manage to make an occlusion culling system on your own, id really appreciate it if you could share it and I can incorporate it into ChunkLoader as a feature. Of course, you dont have to, but just saying.

Also, the post just hit 1k views!

Ill try to start on the viewport detection. It might not be great though :sweat_smile:

Add my discord: saintimmor

I have made my own implementation of the module using QuantumNode’s DOS (Dynamic Octree System) for faster queries:


local ReplicatedStorage = game:GetService('ReplicatedStorage')
local RunService = game:GetService('RunService')
local Players = game:GetService("Players")

local modules = ReplicatedStorage.Modules

local NetSignal = require(modules.OOP.NetSignal) -- This is a signal creator module i've made
local DynamicOctree = require(modules.OOP.DynamicOctree)

local unrendered = if ReplicatedStorage:FindFirstChild("Unrendered") then 
	ReplicatedStorage.Unrendered else Instance.new("Folder")

unrendered.Name = "Unrendered"
unrendered.Parent = ReplicatedStorage

local Constructor = {}

local ChunkLoader = {}
ChunkLoader.__index = ChunkLoader

type RBXScriptSignal = NetSignal.RBXScriptSignal

function Constructor.new(minDistance: number, targetDistance: number, chunkSize: number, renderOrigin: Part | Camera, dontUnrender: {Instance})
	local self = setmetatable({}, ChunkLoader)
	
	local minMoreThanTarget = minDistance > targetDistance
	self.minDistance = if minMoreThanTarget then 3 else minDistance
	self.targetDistance = if minMoreThanTarget then 5 else targetDistance
	
	self.chunkSize = chunkSize

	self.renderedChunks = {}
	self.octree = DynamicOctree.New("Renderer", 6, chunkSize)
	self.dontUnrender = dontUnrender

	self.renderOrigin = renderOrigin
	
	self.ItemRendered = NetSignal:Create(
		"ItemRendered",
		{
			connectType = function(item: BasePart)end,
			fireType = function(self: RBXScriptSignal, item: BasePart)end,
		},
		{
		}
	)
	self.ItemUnrendered = NetSignal:Create(
		"ItemUnrendered",
		{
			connectType = function(item: BasePart)end,
			fireType = function(self: RBXScriptSignal, item: BasePart)end,
		},
		{
		}
	)

	Constructor.currentChunkLoader = self

	return self
end

function ChunkLoader:IsPositionWithinRenderDistance(position: Vector3)
	local renderOrigin = self.renderOrigin.CFrame.Position

	local distance = (position-renderOrigin).Magnitude

	return distance < self.renderDistance*self.chunkSize
end

-- Add more instances to the dontUnrender list
function ChunkLoader:DontUnrender(instances: Instance | {Instance})
	if typeof(instances) == "Instance" then
		table.insert(self.dontUnrender, instances)
	elseif typeof(instances) == "table" then
		for _, inst in instances do
			table.insert(self.dontUnrender, inst)
		end
	end
end

-- Gets the position of a chunk using the specified position
function ChunkLoader:GetChunkPosition(position: Vector3)
	local x = math.floor(position.X / self.chunkSize)
	local z = math.floor(position.Z / self.chunkSize)

	-- Calculate the corner position
	local cornerX = x * self.chunkSize
	local cornerZ = z * self.chunkSize

	return Vector2.new(cornerX, cornerZ)
end

local originalParents = {}
-- Updates all chunks using RadiusSearch
function ChunkLoader:UpdateChunks()
	local minChunks = (self.minDistance*self.chunkSize)^2
	local targetChunks = self.targetDistance*self.chunkSize
	
	local updatedChunks = 0
	local items, distancesSq = self.octree.Tree:RadiusSearch(
		self.renderOrigin.CFrame.Position, 
		targetChunks
	)
	
	for i, item: Instance in items do
		local distanceSq = distancesSq[i]
		
		if distanceSq > minChunks then
			item.Parent = unrendered
			
			self.ItemUnrendered:Fire(item)
		else
			local originalParent = originalParents[item]
			item.Parent = if originalParent ~= nil then originalParent else workspace
			
			self.ItemRendered:Fire(item)
		end
	end
end

-- Fills cache to be rendered or unrendered
function ChunkLoader:FillCache(origin: Vector3, radius: number)
	local originChunk = self:GetChunkPosition(origin)
	local chunkHeight = 2000

	local overlapParams = OverlapParams.new()
	overlapParams.FilterType = Enum.RaycastFilterType.Exclude
	overlapParams:AddToFilter(self.dontUnrender)

	local processedChunks = 0
	local playerCharacterFilter = {}

	-- Gather all player characters once and add to the filter
	for _, player in pairs(Players:GetPlayers()) do
		if player.Character then
			table.insert(playerCharacterFilter, player.Character)
		end
	end
	overlapParams:AddToFilter(playerCharacterFilter)

	local size = Vector3.new(self.chunkSize, chunkHeight, self.chunkSize)

	for dz = -radius, radius do
		for dx = -radius, radius do
			local chunkPosition = Vector2.new(originChunk.X + (dx * self.chunkSize), originChunk.Y + (dz * self.chunkSize))
			local cframe = CFrame.new(chunkPosition.X + self.chunkSize, 0, chunkPosition.Y + self.chunkSize)
			
			local query = workspace:GetPartBoundsInBox(cframe, size, overlapParams)
			if #query < 2 then continue end

			for _, part: BasePart in query do
				originalParents[part] = part.Parent
				self.octree:Add(part, part.Position)
				
				part.Parent = unrendered
			end
			-- Check if elements are empty, this saves on memory by not storing empty chunks
			
			processedChunks += 1
			if processedChunks % 70 == 0 then
				RunService.Heartbeat:Wait()
			end
		end
		RunService.Heartbeat:Wait()  -- Extra wait after completing each row
	end
end

-- Autoupdates chunks every renderstep
function ChunkLoader:BindToRenderStepped()
	RunService:BindToRenderStep(
		"ChunkLoader",
		Enum.RenderPriority.Camera.Value,
		function()
			self:UpdateChunks()
		end
	)
end

-- Autoadds any instances to the cache
function ChunkLoader:CacheAddedDescendants()
	game.DescendantAdded:Connect(function(descendant: BasePart|Model)
		
	end)
end

function ChunkLoader:EndProcess()
	self:UnbindFromRenderStepped()
	
end

function ChunkLoader:UnbindFromRenderStepped()
	RunService:UnbindFromRenderStep("ChunkLoader")
end

return Constructor