Assistance with Calculating Angles for Tank Turret Orientation

Please do not ask people to write entire scripts or design entire systems for you. If you can’t answer the three questions above, you should probably pick a different category.

Hi! I’m current working on some pet projects and I’m just diving back into coding, with some minor successes - however, I’ve run into a couple stumbling blocks, hence why I’m now posting.

I am working on moving a tank turret and gun towards a target, and I’ve explored a couple options of doing so - again, with minor success. I started by trying to use Linear Interpolation to angle the turret and gun, but I ran into issues with the various welds on the tank.

So instead, I used Motors to connect the gun to the turret, and the turret to the hull - which does work as I would like. However, the issue I ran into was accurately calculating the angles necessary to orient those motors correctly; first starting with some classic trigonometry (which I’ve arrived back to after pursuing other solutions) - at least with the trigonometry, I started with using the math.sin function but quickly realized that it’s not particularly useful for accurately representing the quadrants. Then, I found atan2 and tried to adapt it, but failed.

function MoveTurret(x,y)
	local found, message = pcall(function()
		Pivots:FindFirstChildWhichIsA("Motor")
	end)
	if found then
		for _,child in pairs(Pivots:GetChildren()) do
			if child:IsA("Motor") then
				if child.Name == "Gun_Pivot" then
					child.DesiredAngle = y
				elseif child.Name == "Turret_Pivot" then
					child.DesiredAngle = x
				end
			end
		end
	else
		Pivots.ChildAdded:Connect(function(child)
			if child:IsA("Motor") then
				if child.Name == "Gun_Pivot" then
					child.DesiredAngle = y
				elseif child.Name == "Turret_Pivot" then
					child.DesiredAngle = x
				end
			end
		end)
	end
end

function CalculateAngleFromTarget(target,pivot_x,pivot_y)
	local dis_y = -(pivot_y.Position.Y-target.Position.Y)
	local dis_xz = (Vector3.new(pivot_y.Position.X,0,pivot_y.Position.Z)-Vector3.new(target.Position.X,0,target.Position.Z)).Magnitude
	local ytan2 = math.atan2(dis_y,dis_xz)
	
	local dis_x = (pivot_y.Position.X-target.Position.X)
	local dis_z = (pivot_y.Position.Z-target.Position.Z)
	local xtan2 = math.atan2(dis_x,dis_z)
	
	local xy = {xtan2,ytan2}
	return xy
end

game:GetService("RunService").Heartbeat:Connect(function()
	local angle = CalculateAngleFromTarget(script.Parent.Parent.Target,Pivots:FindFirstChild("Turret_Pivot"),Pivots:FindFirstChild("Gun_Pivot"))
	local angle1 = angle[1]
	local angle2 = angle[2]
	MoveTurret(angle1,angle2)
end)

If you have any tips on reconciling this, I would appreciate it - as well as any alternative methods, knowledge about CFrame and/or the various API-integrated math functions. I’m trying to learn as much as possible so any take on this would be appreciated.

4 Likes

Ok so this is actually a fun problem that I had to deal with a few months ago working on Galaxy Warriors. We had these spaceships with multiple cannons that had to point toward the mouse cursor. I had the cannons rigged up using hinge motors.

The easiest way to do this is to ignore the raw trigonometry and step into linear algebra by measuring the angles between look vectors. While that sounds complex, it’s actually really simple to implement.

To get the angle (in radians) between two vectors (normalized vectors that represent a direction, e.g. LookVectors), we take the inverse cosine of the dot product between the two vectors like so:

local function AngleBetween(vectorA, vectorB)
	return math.acos(math.clamp(vectorA:Dot(vectorB), -1, 1))
end

[See math breakdown here] You will see that they also divide by the product of both vector magnitudes. This is unnecessary when using unit vectors because the magnitude is always 1, thus is redundant.

That’s nice and all, but it is unsigned. In other words, it will always spit back a positive angle. This isn’t very useful if we are trying to figure out what angle to set our turret. In order to make a signed version (e.g. it will spit back negative and positive angles), we can abstract the current AngleBetween function into another one like so:

local function AngleBetweenSigned(vectorA, vectorB, axis)
	local unsigned = AngleBetween(vectorA, vectorB)
	local cross = vectorA:Cross(vectorB)
	local sign = math.sign((axis.X * cross.X) + (axis.Y * cross.Y) + (axis.Z * cross.Z))
	return (unsigned * sign)
end

You will see that now we also need to supply an axis, which tells the algorithm what axis we are trying to rotate about. This will typically just be whatever “up” is considered in your system.

Alright, so now we can calculate the angle between two vectors. Now we can use this to determine what angle to set our hinge motor. In order to do this, we need both the look vector of our turret and the look vector toward the target. The former we can derive straight from the cframe.LookVector property. The latter can be derived by doing something like (target.Position - turret.Position).Unit. We then feed that into the AngleBetweenSigned function to get our result:

-- Figure out our look vectors:
local targetPosition = thatRedCircleThing.Position
local turretPosition = yourTurret.Position
local turretLookVector = yourTurret.CFrame.LookVector
local targetLookVector = (targetPosition - turretPosition).Unit

-- Calculate the angle difference:
local axis = Vector3.new(0, 1, 0) -- Up
local angle = AngleBetweenSigned(turretLookVector, targetLookVector, axis)
motor.DesiredAngle = math.deg(angle)

This makes an assumption that your turret.CFrame.LookVector actually points forwards. Sometimes when importing meshes, things are a bit wonky. If it ends up being backwards, just negate it using -turret.CFrame.LookVector. If it’s rotated 90 degrees, use turret.CFrame.RightVector or -turret.CFrame.RightVector.

26 Likes

I gotta say man, I was hoping for one of you big wigs to respond and you absolutely 100% delivered - I had a few rudimentary solutions that I thought about doing but I was sure that there was a more efficient way that I was missing.

Thank you very much for the detailed response, code examples, and explanations; the different Vector3, CFrame, and math functions I’m definitely gonna be using those in the future. :eyes:

1 Like

Fun fact: math.acos2 will return signed values and then you don’t need that additional logic. :stuck_out_tongue:

4 Likes

As far as I can tell, Luau doesn’t have a math.acos2. But this would be very handy.

Nvm you probably meant atan2.

I’m curious, how would you implement it with atan2?

I can write up an explanation when I’m home in a few hours, but just to clarify: you want to rotate the top of the tank toward the point (yaw) and then rotate the barrel to look at the point (pitch)?

That’s the essence of it, if there’s a simpler and more intuitive way to do it, then I’m the guy who wants to know.

No that’s exactly how you should do it. I wanted to clarify since it looks like @sleitnick’s solution only does yaw and doesn’t account for the red point being above/below the tank’s local XZ plane.

1 Like

First you would want to transform the direction to your target into the object space of your tank, and then you use math.atan2 to get the pitch and yaw angles from the vector’s xyz coordinates

local tankCFrame = CFrame.new()

function anglesToTarget(target)
    --vector from tank position to target position
    local vectorToTarget = target - tankCFrame.Position
    --transform it into object space of tank cframe
    local transformedVector = tankCFrame:VectorToObjectSpace(vectorToTarget)
    
    --calculate angles based on coordinates
    local pitch = math.atan2(transformedVector.Y, transformedVector.X)
    local yaw = math.atan2(transformedVector.X, -transformedVector.Z)
    return pitch, yaw
end

You know from trigonometry that math.atan(y/x) gives you the angle of the vector (x, y). math.atan2(y, x) is exactly the same, but instead it calculates the y/x ratio for you, given the two coordinates

So to get your pitch (up/down rotation), you just retrieve the angle of y/x. To get your yaw you must get the angle of x/(-z). We negate Z here because (0, 0, -1) is forward for CFrames

4 Likes

I ended up having some issues using the second half of the code you wrote, specifically with the pitch calculation - I just spent hours trying to find a fix and ended up figuring it out! I made some adjustments to the code you had written, putting my own little twist on it. :smile:

function AngleSolve(target,part)
	local vectorToTarget = target.CFrame.Position - part.CFrame.Position
	local transformedVector = part.CFrame:VectorToObjectSpace(vectorToTarget)
	local x = math.atan2(-transformedVector.X,-transformedVector.Z)
	
	local y_LookVector = (CFrame.lookAt(part.CFrame.Position,target.CFrame.Position).LookVector)
	local y_sign = (math.sign(part.CFrame.Y-target.CFrame.Y))
	local y = math.abs(math.atan(y_LookVector.Y,y_LookVector.X)) * y_sign
	
	return y,x
end

The first part remained virtually the same, but I had some issues with directionality that I fixed with tweaking the signs - not sure why that fixed it, but I noticed that it was just pointing the opposite direction so I just negated the signs.

The second part was the stubborn one though: what was happening was that it was misidentifying quadrants, moving opposite to the target, or incorrectly angled - all dependent on the quadrant the target was in. I think this was happening in part due to how the vector was calculated and maybe an inappropriate use of atan2; I don’t know, I’m not a doctor.

However, what I did instead was to simply calculate the vector by using LookVector of the compiled target and turret positional vectors, then I took the X-value and Y-value (as you had previously noted in your response, thank you :smiley: ) and took the atan of them. All I needed then was the signage, as it doesn’t indicate quadrant - to which I simply compared the the target and turret’s vertical position.

As I’ve noted before, I’m sure there is some API function or mathematical wizardry that can make this simpler, but this is what ended up working. Thank you very much for your assistance, I wouldn’t have done it without you and others whom had responded.

1 Like

Oh my gosh, sorry you had to spend so many hours on it! That’s on me :sweat_smile:
I realise now that you can’t just use Y and X for pitch, because if your target is towards say (0, 2, 5), then X would be zero, and the angle would return 90 degrees!
Instead, you should be able to use the pythagorean theorem to get the distance of (x, z) first, then plug that into atan2, like so:

local tankCFrame = CFrame.new()

function distance(a, b)
    return math.sqrt(a * a + b * b)
end

function anglesToTarget(target)
    --vector from tank position to target position
    local vectorToTarget = target - tankCFrame.Position
    --transform it into object space of tank cframe
    local transformedVector = tankCFrame:VectorToObjectSpace(vectorToTarget)
    
    --calculate angles based on coordinates
    local pitch = math.atan2(transformedVector.Y, distance(transformedVector.X, transformedVector.Z))
    local yaw = math.atan2(transformedVector.X, -transformedVector.Z)
    return pitch, yaw
end

Now the reason you also had to flip signs for the yaw might be because of what sleitnick mentioned, that your tanks don’t point towards (0, 0, -1) by default, which is the forward direction for CFrames. This means the CFrame.LookVector of your tank could actually be the side direction of the tank, or the back.
So either flip the signs like you already did, or make sure your tanks’ forward direction is also (0, 0, -1). It doesn’t really matter as long your tanks have a consistent orientation, else you might get all sorts of weird aiming bugs depending on which tank you’re using

5 Likes

there is also a method called CFrame.fromAxisAngle. You can give it the cross product of turret vector and target vector and the angle which can be calculated with your function and then you get a Cframe that represents the shortest rotational path between those two. These are my thoughts but I could be wrong.