3D relative angles


what you see is basically what I want to achieve, I want to calculate the relative angle in a 3d vector but so far all solutions are buggy.

Atan2 is buggy and same for atan. (some angles atan2 rotates fast or slow same for atan)

I’ve tried chatgtp, YES IM THAT DESPARITE : ( but maybe I can use Dot or ToObjectSpace but I need assistance.

1 Like

Some questions to help clarify what you mean by “relative angle”:

  1. Do you want the angle between two directions e.g. the angle between where my player is facing and some other position in 3D space?

  2. or; do you mean the angle between two directions on a specific axis?

  3. or; do you just want the angle on the horizontal plane? i.e. if you were to look down on the player, the angle between where they’re looking and some object behind them?

What I mean by “relative angle” is the angle on one axis, although this is a custom rotation system im making so by “one axis” I mean the local axis of the part im rotating, to simplify it im looking to calculate the relative angle in 3d positions

local axis is this (sorry for bad illustration)

and also since I didn’t say this in the beginning:


do you understand?

1 Like

I’m hoping I’ve understood, but from the pictures it sounds like you want the angle between two directions relative to a particular axis since you’re wanting to rotate an object on a specific axis?

Demo:

Example code
-- demo parts to visualise
local partA = Instance.new('Part')
partA.Name = 'Start_PartA'
partA.Size = Vector3.one*4
partA.CFrame = CFrame.new(0, 2, 0)
partA.Anchored = true
partA.BrickColor = BrickColor.Green()
partA.Parent = workspace

local partB = partA:Clone()
partB.Name = 'Goal_PartB'
partB.CFrame = CFrame.new(0, 2, -12)
partB.BrickColor = BrickColor.Red()
partB.Parent = workspace

local gizmo = Instance.new('Handles')
gizmo.Faces = Faces.new(Enum.NormalId.Front)
gizmo.Style = Enum.HandlesStyle.Movement
gizmo.Adornee = partA
gizmo.Parent = partA


-- function to compute the angle between
-- two direction vectors on the specified axis
--
--   where 'a' & 'b' are direction vectors
--     and 'axis' is the axis of rotation
--
local function computeSignedAngle(a, b, axis)
	axis = axis or Vector3.yAxis

	local sqrA = a.X*a.X + a.Y*a.Y + a.Z*a.Z
	local sqrB = b.X*b.X + b.Y*b.Y + b.Z*b.Z

	local d = math.sqrt(sqrA * sqrB)
	if d < 1e-6 then
		return 0
	end

	d = math.clamp(a:Dot(b) / d, -1, 1)
	d = math.acos(d)

	local c = a:Cross(b)
	local s = math.sign(axis.X*c.X + axis.Y*c.Y + axis.Z*c.Z)
	d *= s

	return d
end


-- demo main

local AXIS = Vector3.yAxis -- i.e. the axis we want to rotate on

game:GetService('RunService').Heartbeat:Connect(function ()
	-- collect the required information
	local partA_Origin = partA.CFrame
	local partA_Translation = partA_Origin.Position
	local partA_Direction = partA_Origin.LookVector
	
	local partB_Translation = partB.Position

	-- get the local axis
	local worldAxis = partA_Origin:VectorToWorldSpace(AXIS)
	
	-- get the direction vector from PartA to PartB
	local targetDirection = (partB_Translation - partA_Translation).Unit
	
	-- calculate the angle
	local angle = computeSignedAngle(partA_Direction, targetDirection, worldAxis)

	-- update partA's rotation relative to the axis
	partA.CFrame *= CFrame.fromAxisAngle(AXIS, angle)
end)

Edit: Updated, accidentally rotated on the local axis instead of the axis & meant to use :VectorToWorldSpace not :VectorToObjectSpace

1 Like

I’ll try your code, will respond when done! hope this works : D

@DevSynaptix NO WAY Its almost fully functional!
The Z axis isn’t working but X and Y does work!

Here’s how it looks, the game is called PhotoV and I’m working on the end chapter of BETA so this is quite secret stuff but its fine:

My bad, I’ve just realised why. I had mispelled math.sign and wrote it as math.sin - will edit the original to fix it. Your handles are looking pretty good though!

As an apology for messing up the spelling earlier, this might be of use to you:

Example transform gizmo
-- demo parts to visualise
local partA = Instance.new('Part')
partA.Name = 'Start_PartA'
partA.Size = Vector3.one*4
partA.CFrame = CFrame.new(0, 2, 0)
partA.Anchored = true
partA.BrickColor = BrickColor.Green()
partA.Parent = workspace

local gizmo = Instance.new('Handles')
gizmo.Faces = Faces.new(Enum.NormalId.Front)
gizmo.Style = Enum.HandlesStyle.Movement
gizmo.Adornee = partA
gizmo.Parent = partA


-- function to compute the angle between
-- two direction vectors on the specified axis
--
--   where 'a' & 'b' are direction vectors
--     and 'axis' is the axis of rotation
--
local function computeSignedAngle(a, b, axis)
	axis = axis or Vector3.yAxis

	local sqrA = a.X*a.X + a.Y*a.Y + a.Z*a.Z
	local sqrB = b.X*b.X + b.Y*b.Y + b.Z*b.Z

	local d = math.sqrt(sqrA * sqrB)
	if d < 1e-6 then
		return 0
	end

	d = math.clamp(a:Dot(b) / d, -1, 1)
	d = math.acos(d)

	local c = a:Cross(b)
	local s = math.sign(axis.X*c.X + axis.Y*c.Y + axis.Z*c.Z)
	d *= s

	return d
end

-- compute the ray-plane intersection point
local function rayPlaneIntersect(origin, direction, planePoint, planeNormal)
	local d = direction:Dot(planeNormal)
	d = (planePoint - origin):Dot(planeNormal) / d
	return origin + direction*d
end

-- screen-world ray-plane intersect
local function computeIntersect(camera, mousePos, translation, planeNormal)
	local ray = camera:ViewportPointToRay(mousePos.X, mousePos.Y)
	local intersect = rayPlaneIntersect(ray.Origin, ray.Direction, translation, planeNormal)
	return intersect
end


-- demo main
local UserInputService = game:GetService('UserInputService')

local MULTIPLIER = 0.1
local AXES = { Enum.Axis.X, Enum.Axis.Y, Enum.Axis.Z }
local PIVOT = {
	[Enum.Axis.X] = Enum.Axis.Y,
	[Enum.Axis.Y] = Enum.Axis.Z,
	[Enum.Axis.Z] = Enum.Axis.X,
}

local camera = workspace.CurrentCamera

local isRotating = false
local rotationAxis = 1
local prevTransform = nil

local function updateAdornee()
	-- just used to update the demo so we can visualise the rotation
	local axis = AXES[rotationAxis]
	print('Selected:', axis.Name)

	local normalId
	if axis == Enum.Axis.X then
		normalId = Enum.NormalId.Front
	elseif axis == Enum.Axis.Y then
		normalId = Enum.NormalId.Top
	elseif axis == Enum.Axis.Z then
		normalId = Enum.NormalId.Right
	end

	gizmo.Faces = Faces.new(normalId)
	gizmo.Color3 = Color3.fromHSV(rotationAxis / 3, 0.8, 0.75)
end
updateAdornee()

UserInputService.InputBegan:Connect(function (input, gameProcessed)
	if gameProcessed then
		return
	end
	
	local inputType = input.UserInputType
	if inputType == Enum.UserInputType.MouseButton1 then
		prevTransform = partA.CFrame
		isRotating = true
	elseif inputType == Enum.UserInputType.Keyboard then
		-- Q / E to toggle the axis we want to rotate for demo purposes
		local keyCode = input.KeyCode
		
		local direction
		if keyCode == Enum.KeyCode.E then
			direction = 1
		elseif keyCode == Enum.KeyCode.Q then
			direction = -1
		end
		
		if direction then
			rotationAxis += direction
			rotationAxis = rotationAxis > 3 and 1 or (rotationAxis < 1 and 3 or rotationAxis)
			
			updateAdornee()
		end
	end
end)

UserInputService.InputChanged:Connect(function (input, gameProcessed)
	if gameProcessed or not isRotating then
		return
	end
	
	local inputType = input.UserInputType
	if inputType ~= Enum.UserInputType.MouseMovement then
		return
	end

	local axis = AXES[rotationAxis]
	local pivotAxis = PIVOT[axis]
	axis = Vector3.FromAxis(axis)
	pivotAxis = Vector3.FromAxis(pivotAxis)

	local transform = partA.CFrame
	local translation = transform.Position

	-- i.e. compute the intersection point on the plane
	-- and then measure the signed angle between our `axis x pivotAxis`
	-- and the world rotation axis
	local worldAxis = prevTransform:VectorToWorldSpace(pivotAxis)
	local position = computeIntersect(camera, input.Position, translation, worldAxis)
	local angle = computeSignedAngle(prevTransform:VectorToWorldSpace(axis:Cross(pivotAxis)), (position - translation).Unit, worldAxis)

	-- update transform
	partA.CFrame = prevTransform * CFrame.fromAxisAngle(pivotAxis, angle)
end)

UserInputService.InputEnded:Connect(function (input)
	local inputType = input.UserInputType
	if inputType ~= Enum.UserInputType.MouseButton1 then
		return
	end
	isRotating = false
	prevTransform = nil
end)


Or if you want to change the angle relative to the starting point:

Gizmo Example 2
-- demo parts to visualise
local partA = Instance.new('Part')
partA.Name = 'Start_PartA'
partA.Size = Vector3.one*4
partA.CFrame = CFrame.new(0, 2, 0)
partA.Anchored = true
partA.BrickColor = BrickColor.Green()
partA.Parent = workspace

local gizmo = Instance.new('Handles')
gizmo.Faces = Faces.new(Enum.NormalId.Front)
gizmo.Style = Enum.HandlesStyle.Movement
gizmo.Adornee = partA
gizmo.Parent = partA


-- function to compute the angle between
-- two direction vectors on the specified axis
--
--   where 'a' & 'b' are direction vectors
--     and 'axis' is the axis of rotation
--
local function computeSignedAngle(a, b, axis)
	axis = axis or Vector3.yAxis

	local sqrA = a.X*a.X + a.Y*a.Y + a.Z*a.Z
	local sqrB = b.X*b.X + b.Y*b.Y + b.Z*b.Z

	local d = math.sqrt(sqrA * sqrB)
	if d < 1e-6 then
		return 0
	end

	d = math.clamp(a:Dot(b) / d, -1, 1)
	d = math.acos(d)

	local c = a:Cross(b)
	local s = math.sign(axis.X*c.X + axis.Y*c.Y + axis.Z*c.Z)
	d *= s

	return d
end

-- compute the ray-plane intersection point
local function rayPlaneIntersect(origin, direction, planePoint, planeNormal)
	local d = direction:Dot(planeNormal)
	d = (planePoint - origin):Dot(planeNormal) / d
	return origin + direction*d
end

-- screen-world ray-plane intersect
local function computeIntersect(camera, mousePos, translation, planeNormal)
	local ray = camera:ViewportPointToRay(mousePos.X, mousePos.Y)
	local intersect = rayPlaneIntersect(ray.Origin, ray.Direction, translation, planeNormal)
	return intersect
end


-- demo main
local UserInputService = game:GetService('UserInputService')

local MULTIPLIER = 0.1
local AXES = { Enum.Axis.X, Enum.Axis.Y, Enum.Axis.Z }
local PIVOT = {
	[Enum.Axis.X] = Enum.Axis.Y,
	[Enum.Axis.Y] = Enum.Axis.Z,
	[Enum.Axis.Z] = Enum.Axis.X,
}

local camera = workspace.CurrentCamera

local isRotating = false
local rotationAxis = 1
local prevTransform = nil
local prevPosition = nil

local function updateAdornee()
	-- just used to update the demo so we can visualise the rotation
	local axis = AXES[rotationAxis]
	print('Selected:', axis.Name)

	local normalId
	if axis == Enum.Axis.X then
		normalId = Enum.NormalId.Front
	elseif axis == Enum.Axis.Y then
		normalId = Enum.NormalId.Top
	elseif axis == Enum.Axis.Z then
		normalId = Enum.NormalId.Right
	end

	gizmo.Faces = Faces.new(normalId)
	gizmo.Color3 = Color3.fromHSV(rotationAxis / 3, 0.8, 0.75)
end
updateAdornee()

UserInputService.InputBegan:Connect(function (input, gameProcessed)
	if gameProcessed then
		return
	end

	local inputType = input.UserInputType
	if inputType == Enum.UserInputType.MouseButton1 then
		local axis = AXES[rotationAxis]
		local pivotAxis = PIVOT[axis]
		axis = Vector3.FromAxis(axis)
		pivotAxis = Vector3.FromAxis(pivotAxis)

		local transform = partA.CFrame
		local translation = transform.Position
	
		prevTransform = transform
		prevPosition = computeIntersect(camera, input.Position, translation, prevTransform:VectorToWorldSpace(pivotAxis))
		isRotating = true
	elseif inputType == Enum.UserInputType.Keyboard then
		-- Q / E to toggle the axis we want to rotate for demo purposes
		local keyCode = input.KeyCode

		local direction
		if keyCode == Enum.KeyCode.E then
			direction = 1
		elseif keyCode == Enum.KeyCode.Q then
			direction = -1
		end

		if direction then
			rotationAxis += direction
			rotationAxis = rotationAxis > 3 and 1 or (rotationAxis < 1 and 3 or rotationAxis)

			updateAdornee()
		end
	end
end)

UserInputService.InputChanged:Connect(function (input, gameProcessed)
	if gameProcessed or not isRotating then
		return
	end

	local inputType = input.UserInputType
	if inputType ~= Enum.UserInputType.MouseMovement then
		return
	end

	local axis = AXES[rotationAxis]
	local pivotAxis = PIVOT[axis]
	axis = Vector3.FromAxis(axis)
	pivotAxis = Vector3.FromAxis(pivotAxis)

	local transform = partA.CFrame
	local translation = transform.Position

	-- i.e. compute the intersection point on the plane
	-- and then measure the signed angle between our `axis x pivotAxis`
	-- and the world rotation axis
	local worldAxis = prevTransform:VectorToWorldSpace(pivotAxis)
	local position = computeIntersect(camera, input.Position, translation, worldAxis)
	local angle = computeSignedAngle((prevPosition - translation).Unit, (position - translation).Unit, worldAxis)

	-- update transform
	partA.CFrame = prevTransform * CFrame.fromAxisAngle(pivotAxis, angle)
end)

UserInputService.InputEnded:Connect(function (input)
	local inputType = input.UserInputType
	if inputType ~= Enum.UserInputType.MouseButton1 then
		return
	end
	isRotating = false
	prevPosition = nil
	prevTransform = nil
end)

1 Like

I think this is a me problem but when rotating the part the object doesn’t want to go rotate at the negative values

Here how it looks (ignore the second dummy):


Rotationorgin[1] holds the original CFrame, Rotationorgin[3] holds the axis and the selected object is pretty self explanatory

edit: and MouseHitAxisPlane() is where the mouse hits on the axis plane

Did you update the computeSignedAngle function to one of the second examples I sent above? The behaviour in the video looks like it’s due to the earlier misspelling of using math.sin instead of math.sign

If you already have, then does MouseHitAxisPlane() use the WorldAxis as the normal of the plane?

And, btw, take a look at the “Gizmo example 2” script - that one is probably better for your current use case because it measures the angle between the initial mouse position and the new position - this is more normal behaviour for transform gizmos

1 Like

I did update the math.sign thing and MouseHitAxisPlane() does not use the variable WorldAxis but just detects where the mouse intersects on a physical part representing the plane , if that’s the cause I can try to fix that

I’ll use the “Gizmo example 2” if so!

edit: actually how can I convert the vector 3 which uses the games x,y&z to the worldAxis x,y&z?

Have you tried using the Angle function? It just gives you the angle between two vectors (the position vectors) and you don’t need to do all that dot product with math.acos math

local vector1 = Vector3.new(1, 2, 3)
local vector2 = Vector3.new(4, 5, 6)

local angle = vector1:Angle(vector2)
local angleOnY = vector1:Angle(vector2, Vector3.yAxis)

print(angle)
print(angleOnY)

Documentation:

Yes I’ve tried that but it’s buggy, By buggy I mean that it can for some reason not rotate at some axis and when it works it freaks out sometimes. DevSynaptix has found the best way to do this but it’s a great suggestion : D ! @DevSynaptix, I just woke up but I’m gonna try using your rayPlaneIntersect() and hope that works, I’ll come back with the results.

1 Like

DevSynaptix, thank you so much. This has been a two year issue for me and you just saved the whole update for PhotoV. Thank you so so much and I hope you have a great life.

Can I pay you or somthin’ or do you wanna be in the credits of PhotoV?

1 Like

Nice work, that looks great - well done on getting it working!

No worries at all, you don’t have to do either of those things; you were the one to put it all together in the end - I’m happy to have just helped :slight_smile:!

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.