Issues with Rigidbody2D.Slide and gravity/jumping

I’m poking around with the new Rigidbody2D.Slide method, so far I’ve got horizontal movement working beautifully - it is an impressive API! I have some questions about implementing jumping, though.

I am struggling to understand how gravity should interact with it. I want no slippage, does that mean I should keep the SlideMovement setting gravity to zero? I calculate gravity on the fly using my desired jump height and time to apex, and modifiers for fall speed and a low gravity state around the apex of the jump, but because it’s recommended to be a kinematic rigidbody I don’t quite know how I’d apply that to the Slide method. Usually I would use a dynamic rigidbody, set the gravity in the physics settings to the calculated gravity value on game start, and gravity scale on the rigidbody2d would change with the state (1x when going up or grounded, e.g. 2x when falling, and e.g. 0.1x when at the jump apex). It would be nice for the examples to have a basic jumping character controller to explore how this should work!

And is there a way to get the “steepness” of the curve? I’d like to add some sort of way to give the player some more momentum when they’re going downhill and sliding (specific game input not rb.Slide, the player will hold shift to enter a sliding state).

Thanks in advance!

Here’s my player code so far:

[RequireComponent(typeof(Rigidbody2D), typeof(PlayerInput))]
public class PlatformingController : MonoBehaviour
{
    [SerializeField] private PlayerStats stats;
    
    private Rigidbody2D.SlideMovement _slideSettings;
    
    private Rigidbody2D _rbody;
    private PlayerInput _input;
    
    private Vector2 _currentVelocity;
    private float _currentVelocityX;

    private Vector2 _moveInput;

    private bool _grounded;
    
    private bool _isJumping;
    private float _jumpStartTime;

    private void Awake()
    {
        _rbody = GetComponent<Rigidbody2D>();
        _slideSettings = new Rigidbody2D.SlideMovement
        {
            // using the calculated gravity:
            // this makes slopes work properly
            // jumping does not work, the player sticks to the floor
            gravity = new Vector2(0, stats.Gravity),
            
            // multiplying it by Time.fixedDeltaTime
            // this makes jumping work properly
            // slopes do not work, the player bounces down them
            //gravity = new Vector2(0, stats.Gravity * Time.fixedDeltaTime),
        };
    }

    private void OnEnable()
    {
        _input = GetComponent<PlayerInput>();
        _input.actions["Move"].performed += Move;
        _input.actions["Move"].canceled += Move;

        _input.actions["Jump"].performed += Jump;
        _input.actions["Jump"].canceled += Jump;
    }

    private void OnDisable()
    {
        _input.actions["Move"].performed -= Move;
        _input.actions["Move"].canceled -= Move;
        
        _input.actions["Jump"].performed -= Jump;
        _input.actions["Jump"].canceled -= Jump;
    }

    private void Move(InputAction.CallbackContext ctx)
    {
        _moveInput = ctx.ReadValue<Vector2>();
    }
    
    
    private async void Jump(InputAction.CallbackContext ctx)
    {
        if (ctx.ReadValueAsButton() && _grounded)
        {
            _isJumping = true;
            _jumpStartTime = Time.time;
            _currentVelocity.y = stats.JumpForce;
        }
        else if (!ctx.ReadValueAsButton() && _isJumping)
        {
            _isJumping = false;
            if (Time.time - _jumpStartTime < stats.TimeForMaxJump)
            {
                // Calculate jump force for min jump height
                float minJumpForce = Mathf.Sqrt(2 * Mathf.Abs(stats.Gravity) * stats.JumpHeightMin);
                _currentVelocity.y = Mathf.Min(_currentVelocity.y, minJumpForce);
            }
        }
    }

    private void FixedUpdate()
    {
        _currentVelocity.x = GetHorizontalVelocity();
        
        if (!_grounded)
            _currentVelocity.y += stats.Gravity * GetGravityModifier() * Time.fixedDeltaTime;
        else if (_currentVelocity.y < 0)
            _currentVelocity.y = 0;
        
        if (_isJumping && !_grounded)
        {
            if (Time.time - _jumpStartTime >= stats.TimeForMaxJump)
            {
                _isJumping = false; // Stop jumping after reaching max jump time
            }
        }

        var results = _rbody.Slide(_currentVelocity, Time.fixedDeltaTime, _slideSettings);
        _grounded = results.surfaceHit.transform != null;
    }

    private float GetGravityModifier()
    {
        if (Mathf.Abs(_currentVelocity.y) < stats.JumpPeakThreshold)
            return stats.JumpPeakGravity;
        if (_currentVelocity.y < 0)
            return stats.FallGravity;

        return 1;

    }
    
    private float GetHorizontalVelocity()
    {
        var targetSpeed = _moveInput.x * stats.TargetRunSpeed;

        return Mathf.SmoothDamp(_currentVelocity.x, targetSpeed, ref _currentVelocityX, 
            GetAcceleration(targetSpeed));
    }

    private float GetAcceleration(float targetSpeed)
    {
        // we're trying to turn
        if (!Mathf.Approximately(Mathf.Sign(targetSpeed), Mathf.Sign(_currentVelocity.x)))
            return _grounded ? stats.TurnSpeed : stats.TurnSpeedAir;
        
        if (Mathf.Abs(targetSpeed) > Mathf.Epsilon) // we're trying to move
        {
            if (_grounded)
                return stats.Acceleration;
            if (Mathf.Abs(_currentVelocity.y) < stats.JumpPeakThreshold) // we're at the peak of the jump
                return stats.PeakAcceleration;
            return stats.AirAcceleration;
        }
        else // we're trying to stop
        {
            if (_grounded)
                return stats.Deceleration;
            if (Mathf.Abs(_currentVelocity.y) < stats.JumpPeakThreshold) // we're at the peak of the jump
                return stats.PeakDeceleration;
            return stats.AirDeceleration;
        }
    }
    
    private void OnValidate()
    {
        stats.CalculateForces();
    }
}

Setting the gravity to the actual gravity value makes the slopes work correctly - no bouncing when moving down slopes. However, doing this means the player cannot jump.

Setting the gravity to gravity * Time.fixedDeltaTime makes jumping work as expected, but when the player moves down the slope they bounce. This feels like a hack, too, especially since the docs say the gravity step is scaled by deltaTime?

If I set surfaceAnchor to (0, -1) the player still bounces a little bit when moving down the slope. If I set it to (0, -2) the slopes are fixed but the player can once again no longer jump.

If I immediately set the player’s rigidbody position to position + (_currentVelocity * Time.fixedDeltaTime) when the player jumps, while the surface anchor or gravity are set such that the slopes work as expected, the player correctly moves for the first frame of the jump, then immediately snaps back down.

I attempted to work around this by setting the surface anchor to zero when the player jumps, then waiting a little bit, then resetting it to -2, which sort of works, but if the player jumps over a platform they immediately snap to it. There are a bunch of weird issues like that when using this method, so that’s probably not correct either :melting_face:

Found a bug! When the rigidbody bumps into a platform from below, it sticks and jutters. My intuition is that’s trying to do too many slide iterations - but it is not a performance issue. The profiler shows no spikes when it happens! There’s a build here, I can provide the full project if that would be helpful! To reproduce all you need to do is jump below the platform:

Hey, is there any way to work around this? I’m currently dealing with the same issue

I just use kinematic motion when there’s something above the player :melting_face: this is how far I got: https://paste.myst.rs/8pbp1ymz

Ah, well, it’s something
thank you!

Hard to know what the issue is but I’ll share the internal C++ code here for the Slide method. In theory, you could implement this in C#, I don’t think I used any special sauce when I wrote it. Maybe it’ll help figure out how it actually works or implement your own.

Rigidbody2D::SlideResults Rigidbody2D::Slide(const Vector2f& velocity, const float deltaTime, const SlideMovement& slideMovement)
{
    PROFILER_AUTO(gPhysics2DProfileRigidbody2D_Slide, this);
   
    // Fetch configuration.
    const int maxIterations = slideMovement.maxIterations;
    const float surfaceSlideAngle = slideMovement.surfaceSlideAngle;
    const float gravitySlipAngle = slideMovement.gravitySlipAngle;
    const Vector2f surfaceUp = NormalizeSafe(slideMovement.surfaceUp, Vector2f::yAxis);
    const Vector2f startPosition = slideMovement.startPosition;
    const bool useStartPosition = slideMovement.useStartPosition;
    const bool useNoMove = slideMovement.useNoMove;
    const bool useSimulationMove = slideMovement.useSimulationMove;
    const bool useAttachedTriggers = slideMovement.useAttachedTriggers;
    const bool useCustomLayerMask = slideMovement.useLayerMask;
    const UInt32 customLayerMask = slideMovement.layerMask.m_Bits;
    Collider2D* selectedCollider = slideMovement.selectedCollider;
    const Vector2f gravity = slideMovement.gravity;
    Box2D::b2Vec2 surfaceAnchorDirection = PhysicsUtility2D::Tob2Vec2(slideMovement.surfaceAnchor);
    const float surfaceAnchorDistance = surfaceAnchorDirection.Normalize();
    const bool useSurfaceAnchor = surfaceAnchorDistance > 0.0f;

    // Initial default results.
    SlideResults results;
    results.position = useStartPosition ? startPosition : GetPosition();
    results.remainingVelocity = velocity;
    results.slideHit.Reset();
    results.surfaceHit.Reset();

    // Validate explicit arguments.
    RETURN_INVALID_ARG_VECTOR2(velocity, velocity, Slide, Rigidbody2D, results);
    RETURN_INVALID_ARG_FLOAT(deltaTime, deltaTime, Slide, Rigidbody2D, results);

    // Ignore very small/negative values or if the slide-count is bad or there's no body or physics-scene.
    if (deltaTime < PHYSICS_2D_SMALL_RANGE_CLAMP ||
        maxIterations < 1 ||
        m_Body == NULL || m_PhysicsScene == NULL ||
        (useSimulationMove && GetBodyType() == BodyType::kStaticBody))
        return results;

    // Ignore if we specify a collider and it's not attached to this rigidbody.
    if (selectedCollider != NULL && selectedCollider->GetAttachedRigidbody() != this)
        return results;

    // Calculate the remaining distance and direction.
    float remainingDistance = Magnitude(velocity * deltaTime);

    // Calculate if we can gravity slide.
    const bool canGravitySlip = remainingDistance < PHYSICS_2D_SMALL_RANGE_CLAMP;

    // Calculate the direction we want to move.
    Box2D::b2Vec2 direction = PhysicsUtility2D::Tob2Vec2(velocity);
    direction.Normalize();

    // Fetch the current body transform.
    Box2D::b2Transform transform = m_Body->GetTransform();

    // Set the start position if selected.
    if (useStartPosition)
        transform.p = PhysicsUtility2D::Tob2Vec2(startPosition);

    // Get the surface offset.
    const float surfaceOffset = box2d_b2_linearSlop * 2.0f;

    // Get the collider(s) to use.
    Collider2D::ColliderArray colliders(kMemTempAlloc);
    if (selectedCollider == NULL)
    {
        GetAttachedColliders(colliders, useAttachedTriggers);
    }
    else
    {
        colliders.push_back(selectedCollider);
    }

    // Fetch the collider count.
    const int colliderCount = colliders.size();

    // Get the collider layer masks.
    dynamic_array<UInt32> colliderLayerMasks(kMemTempAlloc);

    // Does the contact filter override layer masks?
    if (useCustomLayerMask)
    {
        // Yes, so set all the collider layer masks to use the fixed layer-mask.
        for(int i = 0; i < colliderCount; ++i)
            colliderLayerMasks.push_back(customLayerMask);
    }
    else
    {
        // No, so fetch all the collider layer masks from the layer collision matrix.
        const Physics2DSettings& settings = GetPhysics2DSettings();

        for (int i = 0; i < colliderCount; ++i)
            colliderLayerMasks.push_back(settings.GetLayerCollisionMask(colliders[i]->GetGameObject().GetLayer()));
    }

    // Hit results.
    RaycastHit2D& slideHit = results.slideHit;
    RaycastHit2D& surfaceHit = results.surfaceHit;

    // Reset the iteration count.
    int iterationCount = 0;

    // Iterate the slides.
    do
    {
        // Only slide if there's some remaining distance.
        if (remainingDistance > PHYSICS_2D_SMALL_RANGE_CLAMP)
        {
            // Find the slide contact.
            if (FindSlideContact(this, direction, remainingDistance, slideHit, m_PhysicsScene, transform, colliders, colliderLayerMasks) > 0)
            {
                // Yes, so check if the surface is along the move direction.
                // NOTE: We filter contacts by this so this should always be the case!
                const Vector2f& surfaceNormal = slideHit.normal;
                const Vector2f moveDirection = PhysicsUtility2D::ToVector2f(direction);
                const float surfaceDot = Dot(moveDirection, surfaceNormal);
                if (surfaceDot < PHYSICS_2D_SMALL_RANGE_CLAMP)
                {
                    // Yes, so check if the surface angle is below the surface slide angle.
                    const float surfaceAngle = Degrees(Angle(surfaceNormal, surfaceUp)) - PHYSICS_2D_SMALL_RANGE_CLAMP;
                    if (surfaceAngle <= surfaceSlideAngle)
                    {
                        // Did we move a significant amount?
                        if (slideHit.distance > surfaceOffset)
                        {
                            // Yes, so calculate the distance we'd like to move.
                            const float distance = slideHit.distance - surfaceOffset;

                            // Move the transform by the hit distance.
                            transform.p += distance * direction;

                            // Reduce the remaining distance.
                            remainingDistance -= distance;
                        }
                    }
                    else
                    {
                        remainingDistance = 0.0f;
                    }
                }

                // Clamp the movement direction.
                const Box2D::b2Vec2 hitNormal = PhysicsUtility2D::Tob2Vec2(slideHit.normal);
                direction -= b2Dot(direction, hitNormal) * hitNormal;
            }
            else
            {
                // No hits so move the transform by the remaining move.
                transform.p += remainingDistance * direction;

                // No remaining distance so we're done.
                remainingDistance = 0.0f;
            }
        }

        // Are we using a surface anchor?
        // NOTE: We always want to use the surface anchor, even if there's no remaining distance. We just won't do this if there's no delta-time.
        if (useSurfaceAnchor)
        {
            // Yes, so find a contact for the surface anchor.
            if (FindSlideContact(this, surfaceAnchorDirection, surfaceAnchorDistance, surfaceHit, m_PhysicsScene, transform, colliders, colliderLayerMasks) > 0)
            {
                // Did we find a surface anchor?
                if (surfaceHit.distance > surfaceOffset)
                {
                    // Yes, so calculate the distance to move to the surface anchor.
                    const float distance = surfaceHit.distance - surfaceOffset;

                    // Move the transform to the surface anchor.
                    transform.p += distance * surfaceAnchorDirection;
                }
            }
        }
    }
    // Exit conditions.
    while (
        ++iterationCount < maxIterations &&
        remainingDistance > PHYSICS_2D_SMALL_RANGE_CLAMP &&
        direction.LengthSquared() > (surfaceOffset * surfaceOffset));

    // Are we using gravity?
    Box2D::b2Vec2 gravityDirection = PhysicsUtility2D::Tob2Vec2(gravity);
    const float gravityDistance = gravityDirection.Normalize() * deltaTime;
    if (gravityDistance > box2d_b2_epsilon)
    {
        // Yes, so find a contact for the gravity movement.
        if (FindSlideContact(this, gravityDirection, gravityDistance, surfaceHit, m_PhysicsScene, transform, colliders, colliderLayerMasks) > 0)
        {
            // Did we move a significant amount?
            const float hitDistance = surfaceHit.distance - surfaceOffset;
            if (hitDistance > surfaceOffset)
            {
                // Yes, so move the transform by the hit distance.
                transform.p += hitDistance * gravityDirection;
            }

            // Can we gravity slip?
            // NOTE: If the user requests movement then we won't slip.
            if (canGravitySlip)
            {
                // Yes, so check if the surface is along the gravity direction.
                // NOTE: We filter contacts by this so this should always be the case!
                const Vector2f& surfaceNormal = surfaceHit.normal;
                const float surfaceDot = Dot(gravity, surfaceNormal);
                if (surfaceDot < PHYSICS_2D_SMALL_RANGE_CLAMP)
                {
                    // Yes, so check if the surface angle is beyond the surface slip angle.
                    const float surfaceAngle = Degrees(Angle(surfaceNormal, surfaceUp)) + PHYSICS_2D_SMALL_RANGE_CLAMP;
                    if (surfaceAngle >= gravitySlipAngle)
                    {
                        // The surface slip angle has been exceeded to we need to slip with any remaining gravity distance.
                        const float remainingGravityDistance = gravityDistance - surfaceHit.distance;

                        // Clamp the slip direction.
                        const Box2D::b2Vec2 slipNormal = PhysicsUtility2D::Tob2Vec2(surfaceNormal);
                        gravityDirection -= b2Dot(gravityDirection, slipNormal) * slipNormal;

                        // Perform one slide (slope slip).
                        if (FindSlideContact(this, gravityDirection, remainingGravityDistance, slideHit, m_PhysicsScene, transform, colliders, colliderLayerMasks) > 0)
                        {
                            // Did we move a significant amount?
                            if (slideHit.distance > surfaceOffset)
                            {
                                // Yes, so calculate the distance we'd like to move.
                                const float distance = slideHit.distance - surfaceOffset;

                                // Move the transform by the hit distance.
                                transform.p += distance * gravityDirection;
                            }
                        }
                        else
                        {
                            // No hits so move the transform by the whole move.
                            transform.p += remainingGravityDistance * gravityDirection;
                        }
                    }
                }
            }
        }
        else
        {
            // No hits so move the transform by the whole move.
            transform.p += gravityDistance * gravityDirection;
        }
    }

    // Fetch the target position.
    const Vector2f targetPosition = PhysicsUtility2D::ToVector2f(transform.p);

    // If we're using "No Move" then we skip all movement options.
    // NOTE: We'll simply return the target position.
    if (!useNoMove)
    {
        // Are we using a simulation move?
        if (useSimulationMove)
        {
            // Yes, so ask for a simulation move.
            MovePosition(targetPosition);
        }
        else
        {
            // No, so fetch any interpolation pose.
            RigidbodyInterpolationPose2D* pose = GetInterpolationPose();
            if (pose != NULL && pose->enabled)
            {
                // Reset the pose.
                ReadPose3D(pose->positionFrom, pose->rotationFrom);
                pose->positionTo = pose->positionFrom;
                pose->rotationTo = pose->rotationFrom;

                // Flag as overridden.
                pose->setupOverridden = true;
            }

            // Apply the move and wake the body.
            m_Body->SetTransform(transform.p, transform.q.GetAngle());
            m_Body->SetAwake(true);

            // Update the transform pose.
            Vector3f transformPosition;
            Quaternionf transformRotation;
            ReadPose3D(transformPosition, transformRotation);
            GetComponent<Transform>().SetPositionAndRotationIgnoringSpecificSystems(transformPosition, transformRotation, GetPhysicsManager2D()->GetAllPhysicsTransformMasks());
        }
    }

    // Return the results;
    results.remainingVelocity = NormalizeSafe(PhysicsUtility2D::ToVector2f(direction), Vector2f::zero) * remainingDistance;
    results.position = targetPosition;
    results.iterationsUsed = iterationCount;
    return results;
}

inline int FindSlideContact(
    const Rigidbody2D* rigidbody,
    const Box2D::b2Vec2& direction,
    const float distance,
    RaycastHit2D& closestHit,
    PhysicsScene2D* physicsScene,
    const Box2D::b2Transform& transform,
    const Collider2D::ColliderArray& colliders,
    const dynamic_array<UInt32>& colliderLayerMasks)
{
    PROFILER_AUTO(gPhysics2DProfileRigidbody2D_SlideFindContact, rigidbody);

    // Reset contact filter to filter by layer-mask (set-up later).
    // NOTE: We don't want to hit triggers.
    Collider2D::ContactFilter contactFilter;
    contactFilter.m_UseTriggers = false;
    contactFilter.m_UseLayerMask = true;
    // Set-up to only see normals opposing our direction of motion.
    const float normalAngle = Degrees(atan2(-direction.y, -direction.x));
    contactFilter.SetNormalAngle(normalAngle - 89.9f, normalAngle + 89.9f);

    const int colliderCount = colliders.size();

    RaycastHit2DArray allHits(kMemTempAlloc);

    // Iterate body colliders.
    for (int i = 0; i < colliderCount; ++i)
    {
        const Collider2D* collider = colliders[i];

        // Set-up a contact filter to filter by the layer-mask for this collider game-object layer.
        contactFilter.m_LayerMask.m_Bits = colliderLayerMasks[i];

        // Perform the cast and store the hit if we found one.
        RaycastHit2D hit;
        if (PhysicsQuery2D::ColliderCast(physicsScene, &transform, collider, PhysicsUtility2D::ToVector2f(direction), distance, contactFilter, true, &hit, 1) > 0)
        {
            // Only add as a hit if the collision isn't being ignored between the collider pair.
            const Collider2D* hitCollider = reinterpret_cast<const Collider2D*>(Object::IDToPointer(hit.collider));
            if (!physicsScene->GetIgnoreCollision(collider, hitCollider))
                allHits.push_back(hit);
        }
    }

    // Sort and find the closest hit.
    const int resultCount = allHits.size();
    if (resultCount > 0)
    {
        // Yes, so sort the hits by distance.
        std::sort(allHits.begin(), allHits.end(), RaycastHit2D());

        // Fetch the closest hit.
        closestHit = allHits[0];
    }

    return resultCount;    
}

Ah thank you! I might re-implement in C#, it’s a fairly repeatable bug though. What’s the intended use here? A full rigidbody movement replacement, or are you only supposed to use it when moving on a slope?

It should provide dev with the ability to write basic movement controllers. I’ve seen many kinematic controllers written and they are all specialised in one way or another. The idea here is to write something lower-level that you can use with a whole bunch of args to move around a scene, get the queries done and have it provide feedback for you to also make decisions on how to move next. The main thing is that it’s flexible which is why it works with all body types, can move immediately or during a simulation or not move at all an you move the body.

It can be improved too but so far we’ve not seen any requests to improve it despite it being used a lot. That’s not to say it’s already optimal, just that it does provide a lot of functionality and any feedback is taken seriously. :slight_smile:

btw, if you have a simple reproduction case for your issue even if you have found a workaround, I’d be happy to take a look for you.

Note that you can control the iterations which defaults to only a few. Essentially it controls how many query steps it uses and won’t cause performance issues unless you’re doing thousands It tells you how many it used in the results too.