LookAt angle calculated incorrectly when part is in a chain

I am working on a script to make a character look at a target position. It works fine when I am changing the transform of a single part (just the head in this case):

but when I start changing the transforms of multiple parts at once, there is a slight offset from the target position when the parts need to rotate on more than one axis:

My first thought was that because I check attachment.WorldCFrame to find the local angle I need to transform the joint by, maybe I was reading a stale value and not the WorldCFrame after I transformed the motor. Also, maybe I’m updating the Neck before the Waist, so the Neck isn’t accounting for Waist’s offset. I changed the code a bit to force Neck to always update after Waist, and passed the world CFrame * the transform I made to the angle calculator to make sure I wasn’t using stale data, and neither helped me.

Any idea what else it could be? Here is a repro file stripped down to be bare bones and without the hacks I made previously (e.g. forcing Neck to be updated after Waist): LookThing.rbxl (24.1 KB)

ServerScriptService > PerspectiveLookPackage > PerspectiveLookClientService is where the meat of the file is. Relevant code:

local function getTargetAngle(motor, targetPosition)
	local childAttachment = motor.Parent:FindFirstChild(motor.Name.."RigAttachment")
	local rootAttachment = motor.Part0:FindFirstChild(motor.Name.."RigAttachment")
	local rootCFrame = rootAttachment.WorldCFrame

	local rootLookVector = rootCFrame.LookVector
	local rootYaw = math.atan2(rootLookVector.X, -rootLookVector.Z)
	local rootPitch = math.atan2(rootLookVector.Y, math.sqrt(rootLookVector.Z^2 + rootLookVector.X^2))
	
	local lookVector = CFrame.new(childAttachment.WorldCFrame.Position, targetPosition).LookVector
	local pitch = math.atan2(lookVector.Y, math.sqrt(lookVector.Z^2 + lookVector.X^2))
	local yaw = math.atan2(lookVector.X, -lookVector.Z)
		
    local viewAngleLocalSpace = transformLastAngleToCurrentSpace(Vector2.new(yaw, pitch), Vector2.new(rootYaw, rootPitch))
	
	return viewAngleLocalSpace
end

...

RunService.Stepped:Connect(function(_, dt)
	for character, metadata in pairs(viewAngleMetadata) do
		for motorName,motorData in pairs(metadata.Rig) do
			local motor = character:FindFirstChild(motorName, true)
			local spring = motorData.Spring
			
			local targetViewAngle = getTargetAngle(motor, metadata.Target)
			
			spring:SetGoal(targetViewAngle * motorData.Weights)
			
			local viewAngle = spring:Update(dt)

			motor.Transform = motor.Transform * CFrame.Angles(0,-viewAngle.X,0) * CFrame.Angles(viewAngle.Y,0,0)
		end
	end
end)
4 Likes

The issue is that you’re using world axis in calculating the pitch and yaw of the desired lookVector. When the waist has no effect, then this works out because the root CFrame is still world-aligned. The problems begin when this is not the case - as in your examples.

To remedy this, bring the target position to object space and you can calculate the delta angles directly like so:

local function getTargetAngle(motor, targetPosition)
    local childAttachment = motor.Parent:FindFirstChild(motor.Name.."RigAttachment")
    local rootAttachment = motor.Part0:FindFirstChild(motor.Name.."RigAttachment")
    assert(rootAttachment)
    assert(childAttachment)
    local rootCFrame = rootAttachment.WorldCFrame
    local childCFrame = childAttachment.WorldCFrame

    local relativeDelta = rootCFrame:PointToObjectSpace(targetPosition).unit
    local pitch = math.atan2(relativeDelta.Y, math.sqrt(relativeDelta.Z^2 + relativeDelta.X^2))
    local yaw = math.atan2(relativeDelta.X, -relativeDelta.Z)

    return Vector2.new(yaw, pitch)
end

Here’s some images of the results and me messing around with waist weights :stuck_out_tongue:

3 Likes

I would expect even just the head to be slightly off if what your mouse points to is very close to the head, if what you’re manipulating is the neck motor. For example, consider if you were standing outside, looking straight ahead and level at the horizon. There is a coin on the ground that your eyes would need to rotate down by 30 degrees to look right at (the math.atan2 result). The problem is that if you instead bend your neck 30 degrees down, keeping your eyes fixed, your eyes will not end up looking at the coin, they’ll be looking at some point on the ground past the coin. This is not immediately intuitive or obvious when coded up, since it’s a tiny error if the thing you’re looking at is very far away as compared to the distance between where your neck pivots and your eyes. The error grows with the offset between the rotation pivot at the source of the ray, and this is a real issue for aiming handheld weapons.

Then, as soon as you have a second joint involved, you have multiple (often infinitely many) solutions because it’s now an IK problem. If you can restrict the motions to a plane, and as few degrees of freedom as you need, there may be a closed-form solution you can just compute, but for 3D and any combination of different joints and DoFs there is not, you typically have to iterate towards a solution with any of the typical IK approaches (CCD, pseudo-inverse Jacobian, gradient descent, etc.) CCD is easy to implement, fast, and typically works fine when you’re starting from a good approximation of the final solution and just using it to refine. It’s a generalization of the way you might solve the simple head looking case.

The last possible problem is one you’ve alluded to: stale values. If you’re trying to set Motor6D Transforms on Stepped, on a character that is also running animations, you have to deal with not having those values yet and always not only being one frame behind, but have to use a mechanism to not apply the same corrections twice.

Thanks! That works flawlessly. I’m having some difficulty getting this to work with my previous hack though. For some reason there is a jitter that wasn’t there before:

Here is an updated repro file that has the hack added in:

LookThing2.rbxl (59.4 KB)

As a stylistic choice, I’d prefer animations to not be accounted for actually. The headtracking seems more believable when you aren’t robotically aiming at a precise point despite animations.

As for the other issues mentioned like IK & weapon aiming, yeah, those are definitely problematic. I wanted to solve a simplier problem set first and then look at tackling those once this first one was out of the way.

1 Like

I tried several things to identify the jitter…

First, I enforced the order of the rig (Waist first then Neck) by topological sorting the rig. The idea was that maybe the Neck updating before the Waist might be causing some jitter. This didn’t really help with the jitter but I don’t think it’s a bad idea to do regardless.

Then I remembered there was a spring which the angles were fed through before being applied. I disabled the spring and directly set the view-angles and sure enough, there was no jitter - the spring is the source!

So I looked at how the spring was interacted with and then I noticed how lastAngleInUse was being used. Its value is the exact angle of the previous frame; but the spring’s position was set to lastAngleInUse multiplied by the weights which desync’d it from the spring’s previous positions. I’m not sure if this is intentional … but removing the weight multiplication in the RunService.Stepped loop removed the jitter in my testing.

for character, metadata in pairs(viewAngleMetadata) do
    for motorName,motorData in pairs(metadata.Rig) do
        local motor = character:FindFirstChild(motorName, true)
        local spring = motorData.Spring
        assert(motor:IsA("Motor6D"))
        
        local targetViewAngle = getTargetAngle(motor, metadata.Target)
        local worldCf = getWorldCFrame(motor)
        local lastAngleInThisSpace = transformLastAngleToCurrentSpace(motorData.LastWorldAngle, worldCf)
        
        spring:SetGoal(targetViewAngle * motorData.Weights)
        spring:SetPosition(lastAngleInThisSpace) --previously was multiplied by the weight
        
        local viewAngle = spring:Update(dt)
        local viewCFrame = CFrame.Angles(0,-viewAngle.X,0) * CFrame.Angles(viewAngle.Y,0,0)
        motor.Transform = motor.Transform * viewCFrame
        motorData.LastWorldAngle = worldCf * viewCFrame
    end
end
1 Like

The weights are necessary or the SetPosition doesn’t function correctly. If you walk side to side with A/D and looking straight forward, you’ll notice that with the weights the head appropriately stays looking straight, but without them it incorrectly guesses the previous position of the spring.

1 Like

I’m pretty confident the weights are the source of the jitter. In the following graphs, Blue denotes Torso desired angle and Red for Neck’s:

This is holding an angle for several seconds with weight multiplication on previous frame. Notice how rough the straight lines are.

And, without the weights.


With the Waist weight being 1/2 and the Neck being the full 1, the Waist will perform about half the necessary rotation for the Neck and the Neck itself will perform 100% of the remaining rotation. As seen in the above, the Neck and Waist desired angles will be similar in magnitude.

But in the first graph the Waist does not perform a 50% rotation and hardly moves at all, with the Neck doing most the work. This is because multiply the previous frame by the weight limits how much the Waist can actually move - it will never reach its target, always losing half its progress each frame. This has the desirable side effect in that the Waist moves so little that the Neck doesn’t really have to account for Waist movement - there is almost none. When ADAD’ing with weights we get this:

And ADAD’ing without weights brings us the following:


They both don’t result in perfect sine waves but the effect on the former is lesser as the Waist moves so little.

In short, the problem is that the Neck spring should take into account the Waist’s one - they aren’t independent. And if you don’t want jitters, halving the spring’s displacement each frame isn’t the way to go.

4 Likes