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
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