NPC Pathfinding System - Looking for tips to improve performance

Recently I wrote a simple pathfinding npc system along with greatly optimizing it

The npc model properties such as: CanCollide, CanQuery, CanTouch, and CastShadow set to off
Code part has been simplified as I could make it be, instead of setting each NPC CFrame seperately I use Roblox’s BulkMove Api, because as I know this method doesnt apply any updates on physics of the model and only change the position of it.

The system is OOP structured. Client-sided rendering and server-sided spawn replication to all the clients.

My current goal is to improve the performance of my code as much as possible

What I tried and did not work:

  • Using Parallel Luau and creating a seperate actor for each NPC, resulted in almost the whole movement code being REQUIRED to run in serial. The conclusion is using chopped Parallel Luau to move parts is less performant than the serial way.

  • Anchoring all the parts inside of a npc model and not just PrimaryPart with welding them to each other. Did not work for idk reason

My suggestions:

  • PartCache and ObjectCache are looking like a good option to use in my case, although reading their logic of work they didn’t seem anything special.

Current benchmarks

export

Vid: NPC PathFinding System Demonstration
Place: NPC System Stress Test - Roblox

Code
-- Parameters
local StepStuds = 50

function class.New(index : number, model : Model)
	local self = setmetatable({},class)
	self.Index = index
	self.Character = model:Clone()
	self.Character.Parent = workspace.NPCs
	self.CFrame = self.Character.PrimaryPart.CFrame
	self.Path = PathfindingService:CreatePath()

	entities[self.Index] = self
	cFrames[self.Index] = self.CFrame
	parts[self.Index] = self.Character.PrimaryPart
	
	Step(self)
	return self
end

function Step(self)
	while true do
		local randomPoint = Vector3.new(math.random(-walkRange,walkRange), 0, math.random(-walkRange,walkRange))
		self.Path:ComputeAsync(self.Character.PrimaryPart.Position, randomPoint)

		local wps = self.Path:GetWaypoints()

		for i=2,#wps do
			self.CFrame = CFrame.lookAt(self.Character.PrimaryPart.Position, wps[i].Position)
			while true do
				self.CFrame += self.CFrame.LookVector * (StepStuds/Ticks)
				if (wps[i].Position - self.CFrame.Position).Magnitude <= 1 then
					break
				end
				task.wait(1/Ticks)
			end
		end
	end
end

function BulkMove()
	for index, entity in entities do cFrames[index] = entity.CFrame end
	workspace:BulkMoveTo(parts,cFrames,Enum.BulkMoveMode.FireCFrameChanged)
end

I’m up for any of your ideas

Anchored parts cannot be welded together : -)

If you want to squeeze out the most performance, you can convert NPCs into just one skinned mesh, and use bones to move the “limbs”. This ends up being way more performant than having 6 parts.

Incredible result

The performance gain is around 200%
Now with 1000 moving npcs fps lock at around 144-156 fps and with 2500 npcs its 42-46 fps

Even with that huge boost I’m still concerned about efficiency of my code, could you look at it and tell me if there’s something I can do to make it more performant?

Render Script
local Replicate = game:GetService('ReplicatedStorage')
local PathfindingService = game:GetService("PathfindingService")
local runService = game:GetService('RunService')

local Ticks = 60
local walkRange = 200

local class = {}
class.__index = class

local entities = {}

function class.start()
	runService.Heartbeat:Connect(BulkMove)
end

-- Parameters
local StepStuds = 50

function class.New(index : number, model : Model)
	local self = setmetatable({},class)
	self.Index = index
	self.Character = model:Clone()
	self.Character.Parent = workspace.NPCs
	self.ControlBone = self.Character.PrimaryPart.Torso
	self.CFrame = self.ControlBone.WorldCFrame
	self.Path = PathfindingService:CreatePath()

	entities[self.Index] = self
	
	Step(self)
	return self
end

function Step(self)
	while true do
		local randomPoint = Vector3.new(math.random(-walkRange,walkRange), 0, math.random(-walkRange,walkRange))
		self.Path:ComputeAsync(self.CFrame.Position, randomPoint)

		local wps = self.Path:GetWaypoints()

		for i=2,#wps do
			local waypointPosition = Vector3.new(wps[i].Position.X,self.CFrame.Position.Y,wps[i].Position.Z)
			self.CFrame = CFrame.lookAt(self.CFrame.Position, waypointPosition)
			while true do
				self.CFrame += self.CFrame.LookVector * (StepStuds/Ticks)
				if (waypointPosition - self.CFrame.Position).Magnitude <= 1 then
					break
				end
				task.wait(1/Ticks)
			end
		end
	end
end

function BulkMove()
	for index, entity in entities do
		entity.ControlBone.WorldCFrame = entity.CFrame
	end
end

--

Replicate.Events.Server.npcAdded.OnClientEvent:Connect(class.New)

return class