There’s an ironic phrase we use in programming: “it’s not a bug, it’s a feature.” What it usually means is, it’s a bug, but I can’t be bothered to fix it. Or it’s a bug, but other things now rely on the buggy behaviour. Recently, I had an encounter with just such a creature.
It began when a student wanted to smoothly rotate an object. We looked through the Unity scripting reference and found SmoothDampAngle. This seemed exactly what was wanted. However, it demonstrated some odd behaviour.
If the object rotated one way, it would usually come to a smooth stop, but in the other direction it would suddenly stop. After a considerable amount of head scratching and logging output angles we came to the conclusion that this must be a Unity bug. From there, it didn’t take us long to find a thread on the Unity forums about it. That thread began in 2011.
The story from there follows some fairly familiar test report pathology: focussing on irrelevant peripheries, polluting the thread with other problems, outright denial of the bug. Finally, some ten years later, a user makes a test case and reports the bug.
Usually, that would be the end of the story. Once a bug is confirmed, a fix is applied to the development versions, and eventually it will roll out into current stable release. But, once in a while, we follow a different path. And this path doesn’t lead anywhere good.
This method has always been like that. It based on Programming Gem 4 SmoothDamp but with a check to avoid overshooting. Unfortuantelly we cannot change this behaviour since many project could rely on this.
— Unity issue 1310653 resolution note
This leaves two options: write your own version of SmoothDamp without the overshoot prevention code in it, or (if you actually want the overshoot prevention) write a fixed version.
The first of these is by far the easiest and is probably what you want:
public static float SmoothDamp(float from, float to, ref float vel, float smoothTime, float deltaTime)
{
float omega = 2f / smoothTime;
float x = omega * deltaTime;
float exp = 1f / (1f + x + 0.48f * x * x + 0.235f * x * x * x);
float change = from - to;
float temp = (vel + omega * change) * deltaTime;
vel = (vel - omega * temp) * exp; // Equation 5
return to + (change + temp) * exp; // Equation 4
}
This was the code published in Game Programming Gems 4 converted to C#. They also provided a note there on extending it to limit the movement to a maximum speed. This code more closely resembles the Unity version:
public static float SmoothDamp(float from, float to, ref float vel, float smoothTime, float maxSpeed, float deltaTime)
{
float omega = 2f / smoothTime;
float x = omega * deltaTime;
float exp = 1f / (1f + x + 0.48f * x * x + 0.235f * x * x * x);
float change = to - from;
// Clamp maximum speed
float maxChange = maxSpeed * smoothTime;
change = Mathf.Clamp(change, -maxChange, maxChange);
float temp = (vel - omega * change) * deltaTime;
vel = (vel - omega * temp) * exp;
return from + change + (temp - change) * exp;
}
But to diagnose the problem and test potential solutions, I created a small Unity project which you can find on GitHub. This project explains the steps I went through in diagnosing the bug in the README.md
file.
It would not be enough to demonstrate a specific case of this code working with some given parameters. It needs to work for all inputs and needs to be stable over differing frame rates. The test program provides the ability to explore the input space. If there are further bugs, the test framework should be useful in catching them.
Unfortunately, the main issue with the SmoothDamp overshoot protection isn’t so much due to a mistake in the code as missing input. Without which, it would be impossible for the code to judge if the function was returning a value that overshot the target. The error was mostly due to the target moving through the current value rather than the current velocity causing it to overshoot the target value.
Here then, is my fix applied as a wrapper around the original SmoothDamp function:
public static float SmoothDampMovingTarget(float current, float target, ref float currentVelocity, float previousTarget, float smoothTime, float maxSpeed, float deltaTime)
{
float output;
if (target == current || (previousTarget < current && current < target) || (previousTarget > current && current > target))
{
// currently on target or target is passing through
output = current;
currentVelocity = 0f;
}
else
{
// apply original smoothing
output = SmoothDamp(current, target, ref currentVelocity, smoothTime, maxSpeed, deltaTime);
if ((target > current && output > target) || (target < current && output < target))
{
// we have overshot the target
output = target;
currentVelocity = 0f;
}
}
return output;
}
Leave a Reply