Constrain rigidbody movement to spline at high speeds / sharp curves

Hi all, I have a 2.5d platformer game. The character is using rigidbody movement on a spline (using curvy splines) which curves into 3d space in all sorts of ways, while the camera stays fixed to the side so that you see the path and background turning, but maintain a 2d side scrolling perspective.

I’m essentially creating a look rotation based on the spline, then moving the player using that forward vector, and making sure to remove any velocity perpendicular to the path so that the player stays centered on the path even when curving. I’m removing the velocity on that vector instead of projecting all the velocity in the direction of the path so that the player can still jump and fall like normal.

void SetLookRotation()
{
    // get nearest TF and point on spline
    Vector3 p;
    mTF = Spline.GetNearestPointTF(transform.localPosition, out p);

    // Get forward and up vectors of point on spline
    _localHorizontal = Spline.GetTangentFast(mTF);
    _localVertical = Spline.GetOrientationUpFast(mTF);

    // Set look rotation to path 
    transform.rotation = Quaternion.LookRotation(Vector3.Cross(_localHorizontal, _localVertical), _localVertical);
}

void Movement()
{
    Vector3 m = transform.right * groundAcceleration * moveInput;
    rb.AddForce(RemoveCrossVelocity(m));

    rb.velocity = RemoveCrossVelocity(rb.velocity);

    Vector3 localVelocity = transform.InverseTransformDirection(rb.velocity);
    localVelocity.z = 0;
    rb.velocity = transform.TransformDirection(localVelocity);
}

Vector3 RemoveCrossVelocity(Vector3 v)
{
    // get magnitude going in the cross product / perpindicular of localHorizontal and localVertical vector 
    // (essentially the magnitude on "local Z" or to the sides of the player)
    Vector3 crossVelocity = Vector3.Project(v, Vector3.Cross(transform.right, transform.up));

    // and remove it from the vector
    return v -= crossVelocity;
}

The first 2 functions are happening in FixedUpdate() in the order shown.

The problem is, when hitting sharp corners at high speeds, some inertia causes the player to deviate off the center of the path still just ever so slightly, and a lot of that momentum turns into upward momentum, launching the player upwards. Eventually the player can fall off the path completely (I do have a custom gravity acting towards the spline though). It works perfectly at lower speeds though, even when dealing with sharp corners. At least as far as I can tell.

I tried a bit of code from Constraining Rigidbody to Spline - Questions & Answers - Unity Discussions but no luck.

Is there a way I could constrain the player rigidbody on a vector that is not one of the global x/y/z axes? I’ve tried a host of other solutions like setting the transform of the player towards at the center of the spline but I can’t seem to get it without feeling very jerky. Using forces makes the player “rubber band” towards and past the center back and forth. Maybe there is something in my math wrong. In any case, I’m hoping someone could help me make sure that the player will always stay on the center of the spline but only on the vector to the sides of the player’s face direction, so that it doesn’t mess with jumping. Thank you very much in advance!

I’d try changing the velocity via Rigidbody.AddForce with ForceMode.Velocity change, instead of modifying Rigidbody.velocity directly, which effectively overrides the results of the physics solver. The same could be done with AddForce like this:

rb.AddForce(expectedVelocity - rb.velocity, ForceMode.VelocityChange)

You may also try PathMagic, a spline package from the Asset Store that provides direct physics integration. It’s described in the third video in the store page.

This works really well for us, thank you! walgreenslistens Facing same issue here. Help is appreciated.

For potential future visitors, I have figured this out. There are a few components (and a lot more if you’re trying to do full spline based physics, but just to start with movement…)

First we must orient our character, so that our local coordinate system can be referenced with transform.right etc. Luckily this package provides these functions which return useful vectors. I’m sure there is math beyond me to do this otherwise if you are building your own spline system.

void SetLookRotation()
    {
        // get nearest TF and point on spline
        Vector3 p;
        playerTF = currentSpline.GetNearestPointTF(transform.localPosition, out p);

        // Get forward and up vectors of point on spline
        _localHorizontal = currentSpline.GetTangentFast(playerTF);
        _localVertical = currentSpline.GetOrientationUpFast(playerTF);

        // Set look rotation to path 
        transform.rotation = Quaternion.LookRotation(Vector3.Cross(_localHorizontal, _localVertical), _localVertical);
    }

Here I am setting a velocity directly but if you’re using forces it’s the same principle.

if (Mathf.Abs(localVelocityAs_X) <= maxDashSpeed * Mathf.Abs(moveInput))
{
    Vector3 m = transform.right * maxDashSpeed * moveInput;
    rb.velocity = RemoveCrossVelocity(m);
}

localVelocityAs_X is defined as (ran in fixedUpdate/ physics step):

float currLocalVelocityX = (playerTF - prevPositionX) / Time.deltaTime;
localVelocityAs_X = Mathf.Lerp(localVelocityAs_X, currLocalVelocityX, 0.5f);
prevPositionX = playerTF;

Where playerTF is your position on a spline (in this case, using the curvy spline package from the unity asset store. Those spline positions return very small floats so in my case I multiplied playerTF by around 10,000 to make it a more easily readable metric). This is essentially just manually calculating velocity of the player each frame by comparing current position on the spline to last frame’s.

RemoveCrossVelocity is the same as above. Comment explanations should suffice.

Vector3 RemoveCrossVelocity(Vector3 v)
    {
        // get magnitude going in the cross product / perpendicular of local horizontal and local vertical vectors 
        // (essentially the magnitude on "local Z" of the player)
        Vector3 crossVelocity = Vector3.Project(v, Vector3.Cross(transform.right, transform.up));

        // and remove it from the vector
        return v -= crossVelocity;
    }

Finally the solution to the drift. My crude fix was essentially to just adjust the player to the center of the spline every frame. Horizontally, there is no change because it grabs the closest spline point which is calculated by this package to be sort of a float clamped between the start and end of the spline. Vertically, we are being set to the distance the player is from the spline in the local up direction - a fancy way of saying we’re not moving vertically at all. The reason this must be done is to avoid the spline vertical position overwriting the players, and we obviously can’t set this vector back to playerPos.y in our local coordinate space, so we must resort to using a direction vector * the distance from our everchanging floor. This isn’t absolutely ideal at the end of the day, but it works, and there isn’t any extra jitter from it (interpolate on your player’s rigidbody and some camera dampening helps). All in all these together combine to make a player able to accelerate quickly around sharp corners of a spline with physics and intertia will never cause the player to fly off or drift from the center. Take that, rocket physics!

void ResetPlayerToSpline()
    {
        Vector3 P; //closest spline point to player
        float pTf = currentSpline.GetNearestPointTF(transform.position, out P);

        playerHeight = Vector3.Distance(transform.position, P);

        transform.position = P + (transform.up * Vector3.Distance(transform.position, P));
    }

Ultimately for those possibly looking to do some kind of implementation in the future, the biggest thing you’ll run into is a lack of cardinal direction, global oriented axis-based functions and properties normally provided by a game engine. For a primer, here are a few I would use (not including gravity, which is simply opposite your up vector times whatever magnitude):

This one allows you to create a vector using x and y like normal (and z in theory) and run this function to convert it when you actually use the vector in a local space. That way, you don’t have to try and think in directions without names. You can still think of things in terms of x and y:

Vector3 ConvertWorldToLocalVector(Vector3 v)
    {
        Vector3 c;

        c = transform.right * v.x + transform.up * v.y;

        return c;
    }

This is basically the same as what is happening in RemoveCrossVelocity(), but it’s important to reiterate this is how you set velocity in a direction to 0. The second part shows how to get velocity in a certain vector.

void Velocity_ZeroY()
    {
        rb.velocity -= GetLocalVerticalVelocity();
    } 

public Vector3 GetLocalVerticalVelocity() 
    {
        return Vector3.Project(rb.velocity, _localVertical);
    }

Getting height, since you cannot just compare y positions:

height = Vector3.Distance(transform.position, P);

I think that’s all the good stuff I can think of. I noticed a severe lack of resources for created spline based physics movement in games, and I’m guessing now it’s based on the fact that this was quite an undertaking. It has since been brought to my attention that the game “Pandemonium”(1996) is a curvy 3d spline based sidescrolling platformer - just like mine! The main difference seems to be that it isn’t at all based on physics, and I’m not sure from what I can tell if it has pitch changes and gravity to compliment. Hope this helps someone someday, and thank you to those who contributed to the discussion.