clarification:
-i am sure that it is specifically the moving part of the script that causes lag and not just hundreds of models existing in the game because:
a. the lag only happens when several hundred npcs are moving at once, and if the majority of them are standing still, the game runs just fine
b. i believe i optimized the npc models a lot as they all have cantouch, cancollide, and canquery turned off, fluid forces disabled, cast shadow set to false, and massless set to true (i didn’t anchor the parts because i believe that parts need to be unanchored in order to . If there are any other optimizations i can make then let me know please
looking over the code the main problem seems to be the fact that the tween loops every task.wait(), but setting it to anything slower than that makes the npc movement visually jittery as the tween often has to wait until the next loop iteration to check if an update from the server has been received, which results in the npc visually stopping and continuing movement. having said that, setting it to task.wait(0.05) does improve the lag noticably, although cpu usage is still within the yellow to orange with 400 npcs moving at the same time (which is the baseline i use for testing)
here is the full code, you can ignore everything before workspace.terrainfinishedgenerating as that is just setup functions
local workersrepfolder = game.ReplicatedStorage.NPCs
local FolderTable = {}
local OldPositions = {}
local Models = {}
local NpcHealthValues = {}
local GetnpcPos = require(game.ReplicatedStorage.GetNPCPositionClient)
local GetnpcRotation = require(game.ReplicatedStorage.GetNPCRotationClient)
local plr = game.Players.LocalPlayer
local tweenSS = game:GetService('TweenService')
workersrepfolder.ChildAdded:Connect(function(folder)
table.insert(FolderTable, folder)
folder.ChildRemoved:Connect(function(NPC)
if NPC:FindFirstChild('Ignore') then
if NPC.Ignore.Value == true then
if Models[NPC.Name] then
Models[NPC.Name]:Destroy()
Models[NPC.Name] = nil
end
wait(.5)
if not folder:FindFirstChild(NPC.Name) then
OldPositions[NPC.Name] = nil
GetnpcPos.WritePosition(NPC.Name, nil)
end
end
else
if Models[NPC.Name] then
Models[NPC.Name]:Destroy()
Models[NPC.Name] = nil
end
end
end)
end)
local function FindGridFolder(GridValue)
for i, folder in ipairs(workspace.NPCs:GetChildren()) do
if folder.ParentGrid.Value == GridValue then
return folder
end
end
end
local function findNPCmodel(NPC)
local folder = FindGridFolder(NPC.Parent.ParentGrid.Value)
end
local connectedNPCs = {}
local function connectNPC(NPC)
local Attributes = NPC:WaitForChild("ProgressBarAttributes")
local connection
task.spawn(function()
connection = Attributes.AttributeChanged:Connect(function(attribute)
if Models[NPC.Name] then
if attribute == "active" and Attributes:GetAttribute("active") == true then
local ui = game.ReplicatedStorage.TrainingProgressUI:Clone()
ui.Adornee = Models[NPC.Name]
ui.Container.Text.Text = Attributes:GetAttribute("text")
ui.ParentGrid.Value = NPC.Parent.ParentGrid.Value
ui.Parent = workspace.ShortLivespanUIs
local time = Attributes:GetAttribute("time")
local tweeninfo = TweenInfo.new(time, Enum.EasingStyle.Linear, Enum.EasingDirection.In)
local tween = tweenSS:Create(ui.Container.Bar, tweeninfo, {Size = UDim2.new(1, 0, 1, 0)})
tween:Play()
local connection
connection = tween.Completed:Connect(function()
ui:Destroy()
connection:Disconnect()
end)
end
else
warn('returned the connectNPC function because the NPC did not have a model')
return
end
end)
end)
NPC.Health.Changed:Connect(function()
if NPC.Health.Value == 0 then
connection:Disconnect()
connectedNPCs[NPC.Name] = nil
return
end
end)
end
game.ReplicatedStorage.NPCs.ChildAdded:Connect(function(Folder)
for i, NPC in ipairs(Folder:GetChildren()) do
if not NPC:IsA('ObjectValue') and not connectedNPCs[NPC] then
connectedNPCs[NPC.Name] = true
connectNPC(NPC)
end
end
Folder.ChildAdded:Connect(function(NPC)
if not NPC:IsA('ObjectValue') and not connectedNPCs[NPC] then
connectedNPCs[NPC.Name] = true
connectNPC(NPC)
end
end)
end)
repeat wait() until workspace.TerrainFinishedGenerating.Value == true
local universalNpcValues = {}
for i, v in ipairs(game.ReplicatedStorage.UniversalNPCValues:GetChildren()) do
universalNpcValues[v.Name] = v
end
local OldPositions = {} --[npcName] = Vector3
local OldRotations = {} --[npcName] = number (degrees)
local ActiveTweens = {} --[npcName] = Tween
local MOVE_TWEEN_INFO = TweenInfo.new(0.1, Enum.EasingStyle.Linear, Enum.EasingDirection.InOut)
@native function positionsClose(a, b, tolerance)
local dx = a.X - b.X
local dy = a.Y - b.Y
local dz = a.Z - b.Z
return (dx*dx + dy*dy + dz*dz) <= (tolerance)^2 --avoids math.sqrt apparently
end
--angle closeness without heavy modulo use
@native function anglesClose(a, b, tolerance)
local diff = a - b
if diff < -180 then
diff = diff + 360
elseif diff > 180 then
diff = diff - 360
end
return math.abs(diff) <= (tolerance)
end
local universalNpcValues = {}
for i, v in ipairs(game.ReplicatedStorage.UniversalNPCValues:GetChildren()) do
universalNpcValues[v.Name] = v
end
spawn(function()
while task.wait() do
for npcName, model in pairs(Models) do
local newPos = GetnpcPos.GetPosition(npcName)
local newRotDeg = GetnpcRotation.GetRotation(npcName) or 0
local robloxYDeg = newRotDeg
local oldPos = OldPositions[npcName]
local oldRot = OldRotations[npcName]
local activeTween = ActiveTweens[npcName]
local speedValue = universalNpcValues[model.Name].Speed.Value
if not speedValue or speedValue <= 0 then speedValue = 10 end -- fallback speed
if not newPos then
if activeTween then
activeTween:Cancel()
ActiveTweens[npcName] = nil
end
continue
end
local moved = not oldPos or not positionsClose(oldPos, newPos, 0.05)
local rotated = not oldRot or not anglesClose(oldRot, robloxYDeg, 1)
if moved or rotated then
local targetCFrame = CFrame.new(newPos) * CFrame.Angles(0, math.rad(robloxYDeg), 0)
if oldPos then
if activeTween then
activeTween:Cancel()
end
local distance = (newPos - oldPos).Magnitude
local duration = distance / speedValue
if duration < 0.05 then duration = 0.05 end -- clamp minimum tween time for smoothness
local tweenInfo = TweenInfo.new(duration, Enum.EasingStyle.Linear, Enum.EasingDirection.InOut)
local tw = tweenSS:Create(model.PrimaryPart, tweenInfo, { CFrame = targetCFrame })
tw:Play()
ActiveTweens[npcName] = tw
else
model:PivotTo(targetCFrame)
end
OldPositions[npcName] = newPos
OldRotations[npcName] = robloxYDeg
end
end
end
end)
while task.wait(.1) do
--local time = os.clock()
local SelectedGrid = plr.SelectedGrid.Value
if SelectedGrid == nil then continue end
for _, Folder in ipairs(FolderTable) do
--if Folder.ParentGrid.Value ~= SelectedGrid then continue end --IMPORTANT: commented out for the sake of testing heavy load
for i, NPC in ipairs(Folder:GetChildren()) do
if NPC:IsA('ObjectValue') then continue end
local npcName = NPC.Name
local npcParentGridValue = NPC.Parent.ParentGrid.Value
if npcParentGridValue ~= SelectedGrid then
local model = Models[npcName]
if model then
model:Destroy()
Models[npcName] = nil
end
continue
end
local currentModel = Models[npcName]
local npcModelValue = NPC.Model.Value
local healthVal = NPC.Health.Value
local npcTypeObj = NPC:FindFirstChild("NpcType")
if not npcTypeObj then
--this happened ONCE during testing but now im paranoid
continue
end
local NPCStats = game.ReplicatedStorage.UniversalNPCValues[npcTypeObj.Value]
local MaxHealthVal = NPCStats.MaxHealth.Value
if not currentModel and npcModelValue then
currentModel = npcModelValue:Clone()
currentModel.ID.Value = npcName
Models[npcName] = currentModel
currentModel.Parent = FindGridFolder(npcParentGridValue)
if OldPositions[npcName] then
currentModel:PivotTo(CFrame.new(OldPositions[npcName]))
end
elseif npcModelValue == nil then
continue
elseif currentModel and npcParentGridValue == SelectedGrid then
--this seems a little inefficient
local folder = FindGridFolder(npcParentGridValue)
if currentModel.Parent ~= folder then
currentModel.Parent = folder
end
end
if not NpcHealthValues[NPC] then
NpcHealthValues[NPC] = healthVal
elseif NpcHealthValues[NPC] ~= healthVal then
NpcHealthValues[NPC] = healthVal
local billboard = currentModel.HealthBar
if not billboard.Enabled then
billboard.Enabled = true
end
billboard.Container.Number.Text = healthVal.."/"..MaxHealthVal
billboard.Container.Bar.Size = UDim2.new(healthVal / MaxHealthVal, 0, 1, 0)
end
end
end
--print(os.clock() - time)
end```