Procedural animation using PID regulators is framerate dependent and jittery

3 weeks ago 11
ARTICLE AD BOX

This is going to be long.

Context

I am developing a first person shooter. For my weapon animations, I use PID controllers to implement procedurally animated weapon sway. My goal is to make the weapon feel like it has mass and inertia when the player is moving, instead of being bolted to it's parent transform and moving without any delay or sway, which would feel very robotic and unsatisfying.

What I want to achieve is something like this:

desired result

The regulator class is implemented as follows:

namespace Animation { [Serializable] public class PidController { public float P, I, D; private float _integral; private float _previousError; private bool _firstUpdate = true; public float Calculate(float targetValue, float currentValue, float deltaTime) { var error = targetValue - currentValue; var proportional = P * error; _integral += error * deltaTime; var integralTerm = I * _integral; var derivative = 0f; if (!_firstUpdate && deltaTime > 0f) { derivative = (error - _previousError) / deltaTime; } var derivativeTerm = D * derivative; _previousError = error; _firstUpdate = false; return proportional + integralTerm + derivativeTerm; } public void Reset() { _integral = 0; _previousError = 0; _firstUpdate = true; } } }

Then in every iteration (for what this means exactly, keep reading) I store the last rotation of my character object and then in the next iteration, I calculate the angular velocity based on the difference between the current and previous rotation. This works fine.

Using this, I then calculate three kinds of sway (vertical, horizontal and rotational), let's focus on horizontal because all of them are essentially the same. dt is the delta time between the previous and current iterations.

private float UpdateHorizontalSway(float velocityY, float dt) { var target = velocityY * horizontalSwayScale; var yRotation = Mathf.DeltaAngle(0, swayTransform.localEulerAngles.y); var action = horizontalSwayPidController.Calculate(target, yRotation, dt); _horizontalSwayVelocity += action * dt; _horizontalSwayVelocity *= 1f - horizontalSwayDamping; return yRotation + _horizontalSwayVelocity * dt; }

I use the return value to update the current rotation:

var newYRotation = UpdateHorizontalSway(angularVelocity.y, dt) - linearVelocity.x * movementFactor; var newXRotation = UpdateVerticalSway(angularVelocity.x, dt) + linearVelocity.y * movementFactor; var newZRotation = UpdateRotationalSway(angularVelocity.y, dt); swayTransform.localRotation = Quaternion.Euler(newXRotation, newYRotation, newZRotation); Remember();

And then in remember I update _lastRotation.

Approach 1: calculations in update

The above section already contains some implementation details that are tied to this approach, but I wanted to show the actual code. In this approach, I run the simulation once per frame and update the rotation directly. My frame rate is very stable because the scene I am using for testing (and the entire game) has absolutely no content yet. With a stable frame rate in the editor and input values for scale, damping and PID regulators tuned I get nice sway animations.

When I build the game though and run the build, the game obviously ends up running at a much higher FPS count than the editor, since there is presumably much less overhead, so the rate at which the simulation runs is also greatly increased and this is where the issues start.

The simulation now behaves completely differently. The amplitude of the movements and also the speed at which they are being corrected is much larger than in the editor. ChatGPT and Gemini are somewhat in agreement that this is because the PID regulator, due to it's reliance on time, is greatly dependent on sampling rate, but at this point my understanding of math becomes insufficient so I don't know how to fix that. Sampling at a fixed rate but updating at a dynamic rate will make the system over-correct and lead to exaggerated movement and, again, jitter. What I understood to be crucial is that the system responds to it's corrections immediately, so you cannot calculate corrections at a different rate to that at which the system responds to those corrections.

Here you can see this approach in action. While writing this I noticed that these recordings also show noticeable jitter and when moving the mouse at a relatively constant rate, I can also notice it while "playing", but it is less severe in this case than in approach 2, even though in the recordings, that might not come across.

In the Unity editor (~150 fps)

In the Unity editor

In the build (~700 fps)

In the build

Approach 2: calculations at a fixed rate in a thread

My next approach was to run the simulation in FixedUpdate, but this ended up creating incredible jitter, because FixedUpdate usually runs at a much lower frequency than Update, so the visuals are only fed new adjusted rotation values every couple of frames, which leads to stuttery animations. I abandoned this very quickly.

At this point I assume that the simulation needs to run at least at the same interval or more often than Update. So I moved all logic to a separate thread and instead of updating the object's rotation directly, I store it in a member variable which is then used in Update to update the mesh's rotation.

The simulation now runs around 300 times per second, whereas the game in the Unity editor runs at around 150 FPS, so all should be good, to my (incorrect) understanding. I still get jittering though, and I am out of ideas on how to fix it.

editor

I wrote a little tool that plots a time diagram to show when the simulation ran and when Update ran, and it looks like this. Blue are Update calls, black are simulation iterations. You can clearly see that both run at regular intervals. Why do I still get jittering, and how do I fix it?

build

The last resort

My last idea would be to limit the game's frame rate to 60 FPS. This is a frame rate that I will need to achieve anyways in order for players to have an enjoyable experience and it will cover 95% of players, as most gamers (I assume?) don't have monitors at refresh rates higher than that, but I can already see people complaining about it. Limiting the frame rate to something high like 144 FPS wouldn't solve my problem, because that would still cause the animations to behave differently on systems which can't reach a frame rate that high (which is going to be the majority). Also it will rule out vsync, since that (at least in Unity) doesn't seem to be compatible with a maximum frame rate.

My best idea is to accept this fact and tune all animations for 60 fps, accepting that they will feel differently to some players, but this is really unsatisfying and I am burning to understand how I can fix this.

Any suggestions are welcome.

Read Entire Article