Enhancing Game Performance with Procedural Generation and Efficient Memory Management

I’m working on a game that is procedurally generated. The issue was the server gets too much memory usage from generating copies of objects from different libraries.


Solution? Compress the objects not in view of the player to a reference of their source object.
image

Objects are classified into different categories and placed accordingly.

Render Objects of different default sizes are used.
image
Then check the distance of objects to see if they are within the range you want and do the targeting of all npcs and players. Then create a render object and recognize the source object.


Through testing the bottleneck for performance of this system was touch connections
So i ended up using the Zone plus module after using it for something else.
ZonePlus v3.2.0 | Construct dynamic zones and effectively determine players and parts within their boundaries - Resources / Community Resources - Developer Forum | Roblox

Occlusion culling and Payload Execution,
I run this code on the client and cache all the entered zones and fire them to the server as a payload every 1.2-2.4 seconds and check each object with a custom Attribute called “Zoned”

-- This creates a zone for every ambient group, then listens for when the local player enters and exits
--task.wait(1.2)
local Zone = require(game:GetService("ReplicatedStorage").Zone)
local localPlayer = game.Players.LocalPlayer
local playerGui = localPlayer:WaitForChild("PlayerGui")
local Remote=game.ReplicatedStorage.GlobalSpells.ZoneRemote
local zonearray={}
local id=0
local payloadarray={}

local function payloadcache(container)
	table.insert(payloadarray,container)
end

local camera=workspace.CurrentCamera
local function IsInView(object,cameraViewportSize)
	--if math.abs(object.Position.Y-localPlayer.Character.HumanoidRootPart.Position.Y)>50 then return false end
	--print(object.Position)
	local objectPosition = camera:WorldToViewportPoint(object.Position)
	-- Check if the object is within the camera's viewport
	--print(objectPosition)
	if objectPosition.X >= 0 and objectPosition.X <= cameraViewportSize.X and
		objectPosition.Y >= 0 and objectPosition.Y <= cameraViewportSize.Y and
		objectPosition.Z > 0 then -- Z > 0 means the object is in front of the camera
		return true
	else
		return false
	end
end

local objects=workspace.TouchBox
local function checkobjects(pos)--check if object is in view and 
	local cameraViewportSize = camera.ViewportSize
	for i,reference in objects:GetChildren() do 
		if reference:IsA("BasePart") then
			if  IsInView(reference,cameraViewportSize) then
				reference.CanQuery=true
			else 
				reference.CanQuery=false
			end
		end
	end
end
local function RegisterLoop()
	spawn(function() 
		local RefreshRate=2.4--one payload per second
		local prevpos=localPlayer.Character.HumanoidRootPart.Position
		while true do 
			if prevpos~=localPlayer.Character.HumanoidRootPart.Position then
				checkobjects(localPlayer.Character.HumanoidRootPart.Position)
				prevpos=localPlayer.Character.HumanoidRootPart.Position
				Remote:FireServer(payloadarray)
				payloadarray={}
			end
			task.wait(RefreshRate)
		end		
	end)
end





local enum = require(game:GetService("ReplicatedStorage").Zone.Enum)
local connections={}
local function Areatitle(container,zone)

	if container:GetAttribute("Zoned")==false then
		container:SetAttribute("Zoned",true)
	
		local zonearray = Zone.new(container)
		local container=container
		local connection=nil
		--local contain=nil
		--local remove=nil
		--zonearray.accuracy=enum.enums.Accuracy.Low
		connection=zonearray.localPlayerEntered:Connect(function()
			--print("Zone entered "..tostring(container))
			print("Zone interaction")
			container.CanQuery=false	
			payloadcache(container)
			connection:Disconnect()
			zonearray:Destroy()
			
			connection=nil
			zonearray=nil
			container=nil
	
		end)
	end	

end

local ambientAreas = workspace.TouchBox
for _, container in pairs(ambientAreas:GetChildren()) do
	--task.wait()
	Areatitle(container)
end
ambientAreas.ChildAdded:Connect(Areatitle)
--ambientAreas.ChildRemoved:Connect(Areas)
RegisterLoop()

Finally, this is handled by the server like this

local renderer = game.ServerStorage.DarknessNPCs.ModuleScript
local render = require(renderer)
local functionarray = {}

-- Function to interact with all objects within a 20 stud radius
local function interactWithObjectsInRadius(player, object)
	-- Get the position of the object
	local position = object.Position

	-- Find all parts within a 20 stud radius of the object
	local objectsInRadius = workspace.TouchBox:GetDescendants()
	for _, obj in pairs(objectsInRadius) do
		if obj:IsA('Part') and (obj.Position - position).magnitude <= 30 then
			-- Check if the object has a corresponding function in functionarray
			local func = functionarray[obj]
			if func then
				-- Trigger the interact function
				func.interact(player)
			end
		end
	end
end

-- Populate the functionarray with functions from the ModuleScript
for i, v in pairs(workspace.TouchBox:GetChildren()) do
	local func = render[v.Name]
	v.CanTouch=false
	if func then
		functionarray[v] = func(v.Actor:FindFirstChild("DarknessSpawner"))
	end
end

-- Connect the ChildAdded event to update the functionarray
workspace.TouchBox.ChildAdded:Connect(function(v)
v.CanTouch=false	
	local func = render[v.Name]
	if func then
		--functionarray[v] =
			func(v.Actor:FindFirstChild("DarknessSpawner"))
	end
end)


-- Connect the ZoneRemote OnServerEvent to interact with objects within a radius
game.ReplicatedStorage.GlobalSpells.ZoneRemote.OnServerEvent:Connect(function(player, objectsInRadius )
	--print(objectsInRadius)
	for _, obj in pairs(objectsInRadius) do
			--local func = functionarray[obj]
			--	if func then
					-- Trigger the interact function
		local func = render[obj.Name]
		if func then
			--functionarray[v] =
		local func=	func(obj.Actor:FindFirstChild("DarknessSpawner"))
		func.interact(player.Character.HumanoidRootPart)

				end		
		end
end)

In conclusion this brought the performance of my procedurally generated world from 15FPS to 60FPS on a laptop and allows it to run on edge devices.
If you would like to check out the demo you can see it here on my testing server.
Epic RPG with AI +Optimized Performance - Roblox

5 Likes

This is really interesting but there’s a few problems I want to make known that could nullify any benefits of your methodology.

Placing a script inside each and every object will be significantly slower than utilizing tags, Tags are relatively easy to use and proven for optimization systems.

As for code your readability is pretty bad, You can improve this by reducing nesting, cleaning up random variables, reducing hardcoded numbers and using OOP. More specific performance issues like overusing pairs aswell as reliability issues like tons of object references without handling (You should always handle cases where objects dont exist)

Heres how I would handle this :

  • Each source object is registered too a table alongside a identifier of some kind (name)
  • Each tagged object is registered by position into an octree (or simpler a chunk)
  • Whenever the player is in the region of the octree/chunk you can load the objects from their source
  • The tagged objects contain a ‘configuration’ object with attributes (Source object identifier alongside culling information like renderdistance, FOV culling info, etc)
  • For extra performance avoid running code for each object until absolutely neccesary (ex only spawn handlers when the chunk/octree should be loaded then despawn when unloaded)

Im not trying to be rude/disheartening with this because your work is genuinely interesting and I want to see you continue improving

It’s changed a bit now I no longer use zones and I only use occlusion culling. The renderer has been much trial and error. I recently stopped using scripts in each object and now just use a module and a single server script that first compressing the objects to their source object, then it handles when their payload is recieved from the client to unpack the object. In my benchmarks running this on a potato I’ve gotten significant FPS increases and memory reduction. I’ll have to look into the tags sometime, Currently I’m creating a object value with a reference to the source object and also a fall back method for when no source object is found by caching and using a second object value. I also use a bool value called “Rendered”. Their are different sections but it’s self optimizing when objects are cached they no longer have server calculations done on them.
ON the client now I just do occlusion culling and reduce the size of the queried array using this algorithm. Which is currently very effective and uses very little resources.

if Tick.Value>writetime+refresh then
	arrays=objects:GetChildren()	
	writetime=Tick.Value	
end
end

--lower fps shorter render distance
local RefreshRate=.6
local function checkobjects(pos)--check if object is in view and 

	local payload={}
	local cameraViewportSize = camera.ViewportSize
	local fps=playerGui.CharacterSelect.FPS.Value
	local fpstiming=playerGui.CharacterSelect.FPSTiming.Value
	local adjustedarray={}
	for i,v in DistanceArray do 
		adjustedarray[i]=calculateAdjustedRenderDist(v,fps)
	end
	getobjects()
	--local arrays=objects:GetChildren()
	local amnt=math.max(1,#arrays)
	local throttle= RefreshRate/amnt

	local increment=math.max(1,fpstiming/throttle)

	local state=function(reference) if reference.Position.Y<-16 then return adjustedarray[reference.Name] or nil else return nil end end 
	if pos.Y>-16 then
		state=function(reference) if reference.Position.Y>-16 then return adjustedarray[reference.Name] or nil else return nil end end
	end

	local current=0
	task.wait(throttle)
	for i,reference in arrays do 
		current+=1
		if current>increment then
			task.wait(throttle)
			current=0
		end
		if reference:IsA("BasePart") then
			local renderdist=state(reference)--adjustedarray[reference.Name] or nil
			if renderdist then
			local lengthvector=(reference.Position-pos).Magnitude
			if lengthvector<renderdist and IsInView(reference,cameraViewportSize) then				
				table.insert(payload,reference)
				--reference.CanQuery=true
				table.remove(arrays,i)
				reference:Destroy()
			elseif lengthvector>math.min(300,renderdist*2.5) then 
				table.remove(arrays,i)
				--reference.CanQuery=false
			end
			else 
				table.remove(arrays,i)
			end
		else table.remove(arrays,i)	
		end
	end
	--arrays=nil
	state=nil
	return payload,amnt
end

No i get what you are saying about efficiency. I have modified this code, this post is from a month ago.
I already implemented the change you recommended about not keeping functions in memory until they are executed, as demonstrated in this newer ServerRenderer function. I have had great success with my Renderer massively increasing the performance of my game, so there’s nothing you could say to be rude/disheartening
This is the code for my server renderer, a quick rundown of what it’s doing is it connects each object that has been cached into a render object, and fires their compression algorithm based on the name of the object, then each time a payload is received the function they fire that function again while returning the interaction function to rerender the object.

local render = require(renderer)
local functionarray = {}

-- Function to interact with all objects within a 20 stud radius
local function interactWithObjectsInRadius(player, object)
	-- Get the position of the object
	local position = object.Position
	-- Find all parts within a 20 stud radius of the object
	local objectsInRadius = workspace.TouchBox:GetDescendants()
	for _, obj in pairs(objectsInRadius) do
		if obj:IsA('Part') and (obj.Position - position).magnitude <= 30 then
			-- Check if the object has a corresponding function in functionarray
			local func = functionarray[obj]
			if func then
				-- Trigger the interact function
				func.interact(player)
			end
		end
	end
end

-- Populate the functionarray with functions from the ModuleScript
for i, v in pairs(workspace.TouchBox:GetChildren()) do
	local func = render[v.Name]
	v.CanTouch=false
	if func then
	--	functionarray[v] =
		func(v.Actor:FindFirstChild("DarknessSpawner"))
	end
end

-- Connect the ChildAdded event to update the functionarray
workspace.TouchBox.ChildAdded:Connect(function(v)
	local func = render[v.Name]
	if func and v:FindFirstChild("Actor") and v:IsA("BasePart") and v.Parent then
	func(v.Actor:FindFirstChild("DarknessSpawner"))
	--func=nil
	end
end)


-- Connect the ZoneRemote OnServerEvent to interact with objects within a radius
game.ReplicatedStorage.GlobalSpells.ZoneRemote.OnServerEvent:Connect(function(player, objectsInRadius)
	--print(objectsInRadius)
	for _, obj in pairs(objectsInRadius) do
		local func = render[obj.Name]
		if func and obj:FindFirstChild("Actor") and obj:IsA("BasePart") and obj.Parent then
		local objpayload=obj.Actor:FindFirstChild("DarknessSpawner")	
		if objpayload then
		local func2=func(objpayload,obj)
		func2.interact(player.Character.HumanoidRootPart)
		end
		end	
		--obj:Destroy()
	end
end)

My key takeaways and thing I would like to share is that you can very much increase the performance of your game by making a custom renderer, although it’s important to understand what ROBLOX is already doing to optimize rendering.

  1. ROBLOX does not cache the objects on the server, so a growing number of objects on the server can decrease performance or cause a crash if memory limit is exceeded
  2. ROBLOX does not stop isolated objects from running when not in view of other players, so a npc will continue to run it’s scripts and maintain it’s RAM when not in view of a player,

In my case, I am taking care of these issues by compressing objects and destroying them when not in view, also it automatically optimizes npcs not in view of the players so they are not expending resources which allows even more entities to be on the server without negatively impacting performance.
It also drastically frees up ram, in fact every object that has scripts that are running that are cached will not be running thus freeing up resources for others that are relevantly in view,
The main technique here is determining when an object is no in view of a player, and only making this determination once and caching them into a render object. then on the client using Streaming Enabled (to reduce the distance of objects available to be queried) and some logical optimization like the one I shared above you can be very successful with creating a renderer too.

1 Like

In short there are not scripts inside every object. (anymore their use to be :stuck_out_tongue: ) Those scripts has been ported to be handled as mentioned above as a single module script whereas the Key to the function is the name of the object, but the code still currently referencing that location since I basically ported it to the module as simply as possible.