Review for angle math in a headtracking script

I am working on a headtracking script and would like some feedback on how I can improve the current implementation. Here is a minimal file with only bare-bones headtracking (everything is in ServerScriptService):

LookatThingy.rbxl (22.5 KB)

A general overview of the code is that PerspectiveLookerClientUpdater calculates your camera angle every Heartbeat and reports that to PerspectiveLookClientService. PerspectiveLookClientService uses what it’s received from the Updater to calculate and set the waist and neck Transform properties to orientate the character towards where the camera is looking. For some additional context, the reason for the split between these two scripts is for a separate replicator script and other integrations that are not included in the attached file.

I am generally satisfied with all of the scripts in this file aside from PerspectiveLookClientService. I imagine some of what I use for angle math is superfluous, and am also not sure the comments convey the need for and what the two hacks in the file do. I am open to comments regarding things other than those two as well though.


I didn’t give myself the opportunity to check the file for myself out yet, but from the video you linked (and the description you gave), it seems to be running smoothly.
PS: why did you choose Heartbeat over RenderStepped?

RenderStepped is only really useful if you want something to be in sync with the camera, or are updating the camera. See:

While what you’re looking at is your camera’s direction, we’re not particularly in sync with it. Using a spring means our look angle will lag behind the camera (which is a good thing, as it’s less robotic), so having something completely in sync with the camera is more or less pointless.

RenderStepped also blocks actual rendering unlike Heartbeat which can be done in parallel:

so in general it’s a good idea to avoid RenderStepped unless it’s necessary (which it isn’t here)


I have a few improvements to some of your angle math.

function angleDelta (a0, a1)

-- Gives shortest (signed) angle from a1 to a0
	-- Unintuitive order: I would expect this angle to be from a0 to a1
	-- Sign line is very unreadable 
	-- Sign line causes wrong result for some values of a0 or a1 (e.g. a0 = 0, a1 = 3pi)

local function angleDelta(a0, a1)
    local phi = math.abs(a1 - a0) % (math.pi*2)
    local distance = phi > math.pi and math.pi*2 - phi or phi
	local sign = ((a0 - a1 >= 0 and a0 - a1 <= math.pi) or (a0 - a1 <= -math.pi and a0 - a1 >= -(math.pi*2))) and 1 or -1
    return distance * sign

-- Improved version
	-- Appropriate order: signed angle from a0 to a1
	-- Handles all values of a0 and a1 correctly
local function angleDeltaImproved(a0, a1)
	return (a1 - a0 + math.pi) % (math.pi * 2) - math.pi

function normalizeTo360 (theta)

-- Normalizes some theta into the interval [0, 2pi)
	-- The second line inside the function does nothing
	-- This is because (a + b) % b = a % b

local function normalizeTo360(theta)
	theta = theta % (math.pi*2)
	theta = (theta + math.pi*2) % (math.pi*2)  
	return theta

-- Improved version
	-- Removed redundant lines
	-- Note: this is the kind of thing that I would expect to be in-lined rather than as a function call,
		-- because it is such a common operation

local function normalizeTo360Improved(theta)
	return theta % (math.pi * 2)

function normalizeTo180 (theta)

-- Normalizes some theta into the interval (-pi, pi]
	-- Slightly confusing naming: the interval spans 360 degrees, not 180

local function normalizeTo180(theta)
	theta = normalizeTo360(theta)
	if theta > math.pi then
    	theta = theta - math.pi*2
	return theta

-- Improved version 
	-- Does not rely on a secondary function call
	-- Perhaps slightly less readable - but this could use better documentation either way
	-- Minor change to the output interval, which is now [-pi, pi)
	-- Note: uses the same logic as angleDeltaImproved

local function normalizeTo180Improved(theta)
	return (theta + math.pi) % (math.pi * 2) - math.pi

Just to comment: I believe it was originally designed to return a positive value for negative angles. Have you tested this change in output range on the entire project?

I noted this in my comments:

– Normalizes some theta into the interval [0, 2pi)

No need. The operation will always return a positive value in the interval stated above. You can read more about how the modulo operator in Lua works here.


Client module should be using Stepped instead of Heartbeat. This is an animation event/something that should be using the time delta from the physics engine and happen between humanoid update and physics step.