anthonysaade.dev

Parallel Luau Character Sync: Moving Joint Math Off the Main Thread

How Bard’s Fighting Arena poses every nearby character’s neck, torso, hips, and shoulders in parallel under an Actor, then commits the results in one synchronized step.

By · · 3 min read · Advanced

Every character in Bard’s Fighting Arena leans into its movement and turns its head and torso toward where the player is looking. That means rewriting six joint offsets per character, every frame, for everyone near the camera. This post covers how that work runs in parallel with Parallel Luau.

I built this to give characters and movement more life. Having characters strafe and look where their player is looking makes the game feel far more immersive.

Strafing, with the head and torso following the camera.

The shape of the problem

For each visible character, the script needs to:

All of that is math on values that are already there. Only the final step, writing to the Motor6Ds, has to touch the DataModel. That split is what makes it a good fit for Parallel Luau.

Running in parallel

The script lives under an Actor and connects with ConnectParallel, so the per-character work runs off the main thread. It also refreshes which characters are nearby only every 3 seconds, using a spatial query around the camera focus instead of looping over every entity:

local FixedC0s, Bounds = {}, {}
local MotorList : {[Motor6D | typeof(ReplicatedStorage.Aligns)] : CFrame | {CFrame} | number} = {}
RunService.PostSimulation:ConnectParallel(function(DeltaTime)
	if PerformanceMode.Value then
		return
	end

	local LerpDT = 1-math.exp(-12*DeltaTime)

	local Radius = 150 do
		BoundsDelta += DeltaTime
		if BoundsDelta >= 3 then
			if Bounds then
				table.clear(Bounds)
			end
			Bounds = workspace:GetPartBoundsInRadius(Camera.Focus.Position, Radius, HitboxParams)
			if PlayerHurtbox and not table.find(Bounds, PlayerHurtbox) then
				Bounds[#Bounds+1] = PlayerHurtbox
			end
			BoundsDelta = 0
		end
	end

Two details here:

Compute, then commit

Inside the loop, nothing writes to a joint. Each target is stored in a MotorList table instead. Once every character has been processed, task.synchronize() switches back to serial execution and applies everything in one pass:

	task.synchronize() do
		for Motor, Value in next, MotorList do
			local MotorName = Motor.Name
			if MotorName == "RootJoint" then
				for Index, Coordinate in next, Value do
					Motor[Index == 1 and "C0" or "C1"] = Coordinate
					Value[Index] = nil
				end
				table.clear(Value)
			elseif MotorName == "NilNetworkAligns" then
				Motor.SmoothTime = Value
			else
				Motor.C0 = Value
			end
			MotorList[Motor] = nil
		end
		table.clear(MotorList)
	end
end)

The root joint stores both its C0 and C1, so it gets a two-item table. NilNetworkAligns receives a SmoothTime instead of an offset. Everything else is a single C0. Clearing the tables after each frame lets them be reused without allocating new ones.

Why the smoothing uses 1 - math.exp

Every joint eases toward its target with Lerp instead of snapping. The common way to do that is a fixed blend every frame:

Value = Value:Lerp(Target, 0.2)

The catch is that “every frame” means different things on different machines. At 144 FPS that line runs almost five times as often as at 30 FPS, so characters would move faster on high refresh rate monitors.

Instead, the blend amount comes from how much time actually passed:

local LerpDT = 1-math.exp(-12*DeltaTime)

With a fixed blend a, the remaining distance shrinks by (1 - a) each frame, so after one second what is left depends on how many frames ran. With a = 1 - e^(-k·dt), each frame shrinks it by e^(-k·dt), and over one second those multiply to e^(-k) no matter how the second was split up. The result is the same motion at 30, 60, or 144 FPS.

The 12 sets the speed: it covers about 70% of the remaining distance in a tenth of a second, which keeps the joints responsive without looking twitchy.

Results

Here is the same work profiled before and after the move. Running serially, the sync script took 66.586 ms in the script profiler:

Profiler row for the serial Synchronize script, reading 66.586 ms.
Serial.

Running in parallel under the Actor, the same work took 37.765 ms:

Profiler rows for Parallel Luau and its Get task, both reading 37.765 ms.
Parallel.

That is about a 43% drop, for the same poses on the same characters.

Nothing broke along the way. Because the compute phase only reads values and the commit phase is the only place that writes, the move to parallel was completely smooth.

Try it yourself

If you have per-frame work that is mostly reading and math, try splitting it the same way: compute into a plain table in parallel, then write everything after task.synchronize().