Parallel Lua Unit/NPC Implementation Feedback

Hi! With the full release of Parallel Lua, I decided to convert a Unit/NPC implementation to using parallel lua. I wanted to get some feedback to ensure that I am using this feature correctly and efficiently in my implementation.

The original implementation had a single client sided ModuleScript class act as the unit object. I would call unit:Start(), unit:Stop(), unit:UpdateVisual(), ect… in order to replicate it’s position on the server.

image

My new implementation with parallel lua made me throw a bit of the design out of the window as I could not connect any parallel threads without actor instances. It now looks something like this:

image

The UnitReplicator receives updates from the server that a unit has been created and that a client sided ones now needs to be made. It clones the UnitActor instance and calls Updater:Load(...) to set up the unit/attributes with the correct properties. The UnitActor instance has attributes attached to it to allow me to edit properties on without some sort of scope issue due to the actor.

Let me know if more info is needed to understand what I’m doing. Here are relevant snippets from my implementation:

“UnitActor” attributes

image

Here is the relevant “UnitReplicator” code:

UnitReplication.OnClientEvent:Connect(function(action, args)
    if action == "update" then
        local unit = UnitReplicator.Units[args.id]
        if not unit then
            local newUnitActor = UnitActor:Clone()
            newUnitActor.Parent = Actors

            local unitUpdater = require(newUnitActor.Updater)
            unitUpdater:Load(args)

            UnitReplicator.Units[args.id] = {
                actor = newUnitActor,
                updater = unitUpdater
            }

            unit = UnitReplicator.Units[args.id]
        end

        unit.actor:SetAttribute("Active", true)
    elseif ... then
       ...
    end

Here is the “Updater” module code:

if script.Parent.Parent:IsA("ModuleScript") then return end

local Actor = script.Parent
local UnitObject = require(Actor.Parent.Parent.Unit)

local activeSignal = Actor:GetAttributeChangedSignal("Active")
local visibleSignal = Actor:GetAttributeChangedSignal("Visible")
local healthSignal = Actor:GetAttributeChangedSignal("Health")
local startTimeSignal = Actor:GetAttributeChangedSignal("StartTime")
local pauseTimeSignal = Actor:GetAttributeChangedSignal("PauseTime")

local Updater = {}

function Updater:Load(unitProperties)
    Actor.Name = unitProperties.id
    Actor:SetAttribute("StartTime", unitProperties.startTime)
    Actor:SetAttribute("MaxHealth", 100)
    Actor:SetAttribute("Health", Actor:GetAttribute("MaxHealth"))

    self.unit = UnitObject.new(Actor, unitProperties)

    self.active = false
    self.visible = true

    self.replicateConnection = nil
end

function Updater:Destroy()
    if self.replicateConnection then self.replicateConnection:Disconnect() end
    self.unit:Destroy()
end

function Updater:ActiveChanged()
    self.active = Actor:GetAttribute("Active")
    
    if self.replicateConnection then self.replicateConnection:Disconnect() end
    if not self.active then return end

    while self.active and self.visible do
        task.synchronize()
        self.unit:UpdateVisual()
        task.desynchronize()

        task.wait()
    end
end

function Updater:PauseTimeChanged()
    self.unit.pauseTime = Actor:GetAttribute("PauseTime")
end

function Updater:HealthChanged()
    task.synchronize()
    self.unit:SetHealth(Actor:GetAttribute("Health"))
    task.desynchronize()
end

activeSignal:ConnectParallel(function()
    Updater:ActiveChanged()
end)

pauseTimeSignal:ConnectParallel(function()
    Updater:PauseTimeChanged()
end)

healthSignal:ConnectParallel(function()
    Updater:HealthChanged()
end)

return Updater

Here is the “Unit” code:

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

local SharedObjects = ReplicatedStorage:WaitForChild("SharedObjects")
local Path = require(SharedObjects:WaitForChild("Path"))

local RepAssets = ReplicatedStorage:WaitForChild("Assets")
local EnemyAssets = RepAssets:WaitForChild("Enemies")
local BillboardAssets = RepAssets:WaitForChild("Billboards")

local function processUnitModel(model)
    local humanoid = model:FindFirstChildWhichIsA("Humanoid")
    if humanoid then
        humanoid:SetStateEnabled(Enum.HumanoidStateType.FallingDown, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Running, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.RunningNoPhysics, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Climbing, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.StrafingNoPhysics, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Ragdoll, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.GettingUp, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Jumping, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Landed, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Flying, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Freefall, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Seated, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.PlatformStanding, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Dead, false)
        humanoid:SetStateEnabled(Enum.HumanoidStateType.Swimming, false)
        
        humanoid:ChangeState(Enum.HumanoidStateType.Physics)
    end

    for _,object in model:GetDescendants() do
        if object:IsA("BasePart") then
            object.CanCollide = false
            object.CanTouch = false
            object.CanQuery = false
        end
    end
end

local Unit = {}
Unit.__index = Unit

function Unit.new(actor, unitProperties)
    local newUnit = {}
    newUnit.actor = actor

    newUnit.id = unitProperties.id
    newUnit.path = Path.new(unitProperties.pathFolder)

    newUnit.maxHealth = actor:GetAttribute("MaxHealth")
    newUnit.health = actor:GetAttribute("Health")
    newUnit.startTime = actor:GetAttribute("StartTime")
    newUnit.pauseTime = actor:GetAttribute("PauseTime")

    newUnit.model = EnemyAssets.Enemy:Clone()
    newUnit.model.Parent = workspace.Enemies

    processUnitModel(newUnit.model)

    newUnit.healthBar = BillboardAssets["HealthBar"]:Clone()
    newUnit.healthBar.Frame.NameFrame.Label.Text = "Enemy_"..math.random(1,500)
    newUnit.healthBar.Parent = newUnit.model.PrimaryPart

    setmetatable(newUnit, Unit)

    return newUnit
end

function Unit:SetHealth(health)
    if self.healthBar.Parent ~= nil then
        self.health = math.max(0, health)
        self.healthBar.Frame.HealthFrame.Container.Bar.Size = UDim2.fromScale(self.health / self.maxHealth, 1)
    end
end

function Unit:UpdateVisual()
    local cframe = self:GetCFrame()
    self.model:PivotTo(cframe)
end

function Unit:Destroy()
    self.model:Destroy()
end

function Unit:GetCFrame()
    local travelTime = (workspace:GetServerTimeNow() - self.startTime) - self.pauseTime
    local nextNode, currentPosition = self.path:GetLocationFromTime(travelTime, 5, self.currentNode)

    self.currentNode = nextNode - 1
    return CFrame.new(currentPosition, self.path.nodes[nextNode].cframe.Position)
end

function Unit:Hide()
    self.model:PivotTo(CFrame.new(100000, 100000, 0))
end

return Unit

Edit:

I did some tests with the parallel version vs the unparallel version. Weirdly enough the unparallel version performs a lot better. Both get some moderate lag when spawning a unit every 0.01 seconds as you can see here:

Non-Parallel:

Parallel:

I also did an edited version of the parallel implementation that separated the unit’s cframe and PivotTo(…) functionality so I could do the cframe calculations in parallel without having to synchronize for the PivotTo(…):
image

However this was even worse performance wise. I am definitely doing something wrong here.

It does not look like its working because the micro profiler is showing all the work being done on a single worker thread where the npcs should be split between all worker threads

I think the problem might be because your using modules Parallel Luau | Documentation - Roblox Creator Hub so if your loading all the modules from a single actor then all the npcs will run on the same thread

here is a video that might help you

6 Likes

Awesome video! Really helped me get a better understanding of the MicroProfiler. You’re definitely correct, it seems to be clumping all the unit tasks under the “UnitReplicator” module which is the facilitator of creating / updating unit properties. I thought modules would be able to work, but I’ll try using a normal LocalScript with attributes to relay unit property changes.

i found a hacky way to load multiple modules with different actors but using just one script but there might be a better way i just have not had enough time to play with it

task.wait()
if script.Parent == game.ReplicatedFirst.Actor1 then
	local module = require(game.ReplicatedFirst.Actor.ModuleScript)
	script.Parent = game.ReplicatedFirst.Actor2
elseif script.Parent == game.ReplicatedFirst.Actor2 then
	local module = require(game.ReplicatedFirst.Actor.ModuleScript)
	script.Parent = game.ReplicatedFirst.Actor3
elseif script.Parent == game.ReplicatedFirst.Actor3 then
	local module = require(game.ReplicatedFirst.Actor.ModuleScript)
end

this will load the same module 3 times but in different threads I don’t like it but it works

but yes i think you should try the normal LocalScript with attributes idea

1 Like