This project contains my investigations into the Unity SmoothDamp bug. We noticed the issue using SmoothDampAngle
where the behaviour seemed different when rotating clockwise vs anti-clockwise. Researching this problem turned up this thread in the Unity forums.
The origin of the Unity SmoothDamp
function is from the book Game Programming Gems 4 chapter 1.10 pages 95-101. This contains the source code in C++ and a description of how to extend the function to provide maxSpeed
.
Here is the original code converted to C#
public static float Original(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
}
Unfortunately, the extension for maxSpeed
is not fully described, and simply adding the code given in the book doesn't work. Instead I had to re-arrange the expressions to make it work.
public static float Original(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;
}
A Unity developer posted the source code that Unity uses as a comment in the above bug report:
public static float SmoothDampUnity(float current, float target, ref float currentVelocity, float smoothTime, float maxSpeed, float deltaTime)
{
// Based on Game Programming Gems 4 Chapter 1.10
smoothTime = Mathf.Max(0.0001F, smoothTime);
float omega = 2F / smoothTime;
float x = omega * deltaTime;
float exp = 1F / (1F + x + 0.48F * x * x + 0.235F * x * x * x);
float change = current - target;
float originalTo = target;
// Clamp maximum speed
float maxChange = maxSpeed * smoothTime;
change = Mathf.Clamp(change, -maxChange, maxChange);
target = current - change;
float temp = (currentVelocity + omega * change) * deltaTime;
currentVelocity = (currentVelocity - omega * temp) * exp;
float output = target + (change + temp) * exp;
// Prevent overshooting
if (originalTo - current > 0.0F == output > originalTo)
{
output = originalTo;
currentVelocity = (output - originalTo) / deltaTime;
}
return output;
}
The inconsistent behaviour comes from the code Unity added to prevent overshooting:
// Prevent overshooting
if (originalTo - current > 0.0F == output > originalTo)
{
output = originalTo;
currentVelocity = (output - originalTo) / deltaTime;
}
One issue with this code is in the conditional. It is a rare example of an XNOR
(the opposite of an exclusive or) which will be true
if both sides are true
or if both are false
. The first condition originalTo - current > 0.0F
asks if we are moving in a positive direction, the second asks if the output would take us beyond the target. The opposite cases ought to be if we are moving in a negative direction and our output would be before the target, but it includes the case that we are not moving and the output matches the target.
There are two commonly used methods to set the target position for the SmoothDamp
function. I call these, the relative
and absolute
methods.
Using this method we maintain a target position and modify it with time adjusted input:
target += input * speed * Time.deltaTime;
This method produces smooth movement and the smoothed object can take a while to reach the target. Note, despite the documentation, it takes much longer than the smoothTime
to reach the target. The original Gems article notes that a good definition for smoothTime
is "the expected time to reach the target when at maximum velocity." However, since it takes some time to reach maximum velocity and it falls off as you approach the target it takes longer than smoothTime
alone.
Using this method we position the target relative to the current position based on the input:
target = current + input * speed;
This method responds to changes much faster than the absolute method and by definition it reaches target as soon as the input drops to zero. Note that the original Gems version of the function allows the velocity value to come to a smooth halt. Whereas the Unity version attempts to zero out the velocity once the input drops to zero. This (I believe) is the overshoot that they are attempting to prevent with the additional code. As noted earlier, this happens inconsistently in the Unity code.
Included in this project is the scene Graph
which graphs traces from various variables involved in applying the SmoothDamp
function. Each trace is represented by an object in the scene and each has a line renderer linked to the Graph
object. You can turn on and off any of the traces by turning off the objects in the scene.
trace | colour | meaning |
---|---|---|
Distance | red | the distance between the current position and the target |
Velocity | green | the current velocity as maintained by the SmoothDamp function |
Input | blue | the current input * speed |
Position | yellow | the current position value before applying SmoothDamp |
Target | magenta | the target position before updating |
It also has many parameters to help diagnose the issue:
parameter | meaning |
---|---|
Smoothing | the smoothing function to use, including the original Gems function, the current Unity function, and modifications |
Targeting | either relative or absolute positioning of the target as described above |
Width | the width of the line |
Smooth Time | the smoothTime parameter passed to SmoothDamp |
Speed | the speed multiplier applied to the input. This effectively sets the vertical scale of the graph |
Delta Time | the deltaTime parameter passed to SmoothDamp 0.01667 for 60 fps, 0.03333 for 30 fps, and so on |
Time | how much time is covered by the graph compensating for Delta Time. This effectively sets the horizontal scale of the graph |
Positive | the amount of time the input is held positive |
Negative | the amount of time the input is held negative |
Neutral | the amount of time the input is allowed to fall to zero |
Input Change Velocity | how fast the input increases to maximum or falls to zero |
Inspect | a vertical line for inspecting the values at that point in the graph; the values are output in the Inspector window, note that the position is the current position before applying SmoothDamp, whereas the target is the point SmoothDamp will be aiming for |
Max Speed | the maxSpeed parameter passed to SmoothDamp |
Here is an example of the original Gems code using the following setup:
parameter | value |
---|---|
Smooth Time | 1 |
Speed | 2 |
Delta Time | 0.03333 |
Time | 4 |
Positive | 1 |
Negative | 1 |
Neutral | 1 |
Input Change Velocity | 3 |
Max Speed | 20 |
And here is the Unity SmoothDamp
function with the same parameters:
But it is unstable. Change the Delta Time
parameter to 0.01667 (60 FPS) and the curve looks like this:
Looking closely at the first point where the current position is moving positive and crosses the target position. With 0.03333 Delta Time, they cross slightly after frame 41 where the difference between the current value and the target value is almost, but not quite, zero. The overshoot is detected and the velocity is reduced to zero on frame 42. With 0.01667 Delta Time, they first cross on frame 80 where the current value exactly equals the target value. In this case, the overshoot code is not triggered.
Then looking at the second crossing where the current position is moving negatively and crosses the target position. With 0.03333 Delta Time, they cross slightly after frame 103 where the difference between the current value and the target value is again very small, but not zero. The overshoot is detected and the velocity is reduced to zero on frame 104. With 0.01667 Delta Time, they cross on frame 200 where again the current value exactly equals the target value. But this time the overshoot is detected and the velocity is reduced to zero on frame 201.
So it seems that the overshoot check is sensitive to the timing of where the frames fall, unless the velocity is negative, in which case the detection of zero difference between the current and target position always trips the overshoot check.
If we change the overshoot conditional check to:
if (originalTo == current || originalTo > current == output > originalTo)
This fixes the above case and is stable across the range of frame timings. This modified function can be simulated using the Smooth Damp Zero Check
option for the Smoothing
parameter.
However, it does this in a somewhat unsatisfactory way. It detects the overshoot because when the normal test fails, the zero check catches it. This works in the case that the input value drops to exactly zero for at least a frame. But what happens if the input value crosses zero without hitting it?
We can simulate this by dropping the Neutral
parameter to 0. This effectively lowers the time to complete the action so let's drop Time
to 3 as well.
Here is the 0.03333 Delta Time:
Everything looks good here. But here is the 0.01667 Delta Time case:
Both crossings are missed and the current position overshoots the target. What is happening, in this case, is that on the frame before the crossing occurs, it is not detected because in fact the new position does not indeed cross the old target (even though it does cross the next frame's target.)
To catch cases like this we would need to handle cases with a moving target. And that means adding more input to the function:
public static float SmoothDampMovingTarget(float current, float target, ref float currentVelocity, float previousTarget, float smoothTime, float maxSpeed, float deltaTime)
The code for this will be the same as before, but this time changing the overshoot check to:
// Prevent overshooting
if (target == current ||
(previousTarget < current && current < target) ||
(previousTarget > current && current > target) ||
(target > current && output > target) ||
(target < current && output < target))
This works pretty much like the Unity SmoothDamp version in the relative target case, but it is relatively stable and works in both the positive and negative directions.
There is still a small issue which shows up in the cases with slightly different Delta Times (e.g. 0.0165):
The clamping behaviour of the Unity code is causing a slight glitch in the position of the object as the target passes by. This is the clamping behaviour:
output = target;
currentVelocity = (target - output) / deltaTime;
Looking at this code, the first thing that occurs to me is that after output is set to target, (target - output)
will always be zero. So, in fact, we could simplify this to:
output = target;
currentVelocity = 0f;
If the smoothing function causes the current value to overshoot the target (and we have seen previously that this can happen if the distance to the target is small and the current velocity is large) then setting the output to the target seems sensible. But in the case that the target moves through the current position, it would be better to leave the position unchanged, otherwise the delta time step will cause a discontinuity in the position.
The full function handling these cases:
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 = Mathf.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;
}
Note that I am applying the original Unity version here, meaning that the overshoot code is effectively running twice. But you can equally substitute the original Gems version of the smoothing function.
Finally it appears to work consistently and remains stable with changing delta times and performs the way I assume Unity wanted.
I have included the scenes TestSmoothDampRelative
and TestSmoothDampAbsolute
to compare the original Gems code, the Unity code, and my fix.