Skip to content

LSBUGPG/SmoothDamp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

33 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SmoothDamp

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 bug

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.

The use cases

There are two commonly used methods to set the target position for the SmoothDamp function. I call these, the relative and absolute methods.

Absolute target

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.

Relative target

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.

Testing the output

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

original Gems code graph at 30 FPS

And here is the Unity SmoothDamp function with the same parameters:

Unity SmoothDamp graph at 30 FPS

But it is unstable. Change the Delta Time parameter to 0.01667 (60 FPS) and the curve looks like this:

Unity SmoothDamp graph at 60 FPS

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.

Potential Fix

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.

Smooth Damp Zero Check at 60 FPS

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:

Smooth Damp Zero Check target crosses current position at 30 FPS

Everything looks good here. But here is the 0.01667 Delta Time case:

Smooth Damp Zero Check target crosses current position at 60 FPS

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.)

Moving target case

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):

image

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.

About

investigations into SmoothDamp bug

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published