Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

KinematicCharacterController gets stuck on vertical walls #489

Closed
cleak opened this issue Mar 30, 2024 · 3 comments · Fixed by dimforge/rapier#627
Closed

KinematicCharacterController gets stuck on vertical walls #489

cleak opened this issue Mar 30, 2024 · 3 comments · Fixed by dimforge/rapier#627

Comments

@cleak
Copy link

cleak commented Mar 30, 2024

I've observed this behavior in a few projects I've put together so far. When moving a character controller towards a vertical wall at an angle, it will get eventually get stuck. It may slide a bit at first, but it always gets stuck until the direction of movement points away from the wall.

I've tried quite a few things so far including fixing the time step size and increasing offset and nothing has fixed or mitigated the problem. Note that increasing offset wasn't respected - as can be seen in the video below, there's some initial resistance with a large offset when approaching the wall, but it eventually acts as if the offset is 0.

Setup details

I've tried this on Windows 10 & 11 using Bevy 0.13.0 with bevy_rapier3d 0.25.0.

Simple Example

I've put together a small example to capture this behavior in this repo. You can see the full source here.

Video examples

Getting stuck
https://youtu.be/lySzeeX68aI

Large offset not being respected
https://youtu.be/q46BHkLa_s4

@believeinlain
Copy link

I'm having this issue as well. It seems to me that the expected behavior when hitting a wall would be to advance by the component of the desired movement vector that is perpendicular to the wall, but instead it stops because it can't advance exactly in the desired movement direction.

Since the controller will slide a little bit, this seems to be the implemented behavior as well, but I don't know rapier well enough to understand where it's falling short.

My attempt at a workaround may give us a clue, however. I've implemented a system that attempts to correct for this:

fn correct_movement(
    mut query: Query<
        (
            &mut Transform,
            &KinematicCharacterControllerOutput,
        ),
        With<Player>,
    >,
) {
    let Ok((mut transform, controller_output)) = query.get_single_mut()
    else {
        return;
    };
    for (normal, remaining) in controller_output
        .collisions
        .iter()
        .filter_map(|c| c.toi.details.map(|d| (d.normal1, c.translation_remaining)))
    {
        let reject = remaining.reject_from(normal);
        println!("{} reject {} -> {}", remaining, normal, reject);
        transform.translation += reject;
    }
}

It takes the remaining movement vector after a collision along with the normal vector from the collision surface and computes the vector rejection of the remaining movement vector from the normal. However, the normal is often slightly off from where we would expect, which pushes the controller slightly closer to the wall (and in my attempted workaround, through the wall).

For example, in this wall collision, it works as we would expect:

[0.4538327, 0, 0.20984736] reject [-1, 0, 0] -> [0, 0, 0.20984736]

But in this one, it doesn't:

[-0.000004529953, 0, 0.20984736] reject [0.002840163, 0.0027592985, -0.99999213] -> [0.00059146615, 0.00057902705, 0.0000032633543]

I would expect the normal to be [0.0, 0.0, -1.0], but because it's not quite that, my "fix" pushes the character through the wall instead of alongside it.

I'm probably going to keep digging on this, but hopefully someone with a better understanding of rapier has some idea of what is happening.

@cleak
Copy link
Author

cleak commented Mar 31, 2024

Good news, I have a fix though I haven't tested it on a variety of slopes. I dug in pretty deep and it looks like KinematicCharacterController::handle_slopes erroneously triggers climbing mode which stops all horizontal movement. This is confounded by having a small unexpected vertical component of the collision normal against a vertical wall (likely from a previous collision with the floor or an attempt at auto-step, but I haven't dug in deep enough to determine that).

This is the offending branch in handle_slopes is:

if climbing && angle_with_floor >= self.max_slope_climb_angle {
    // Prevent horizontal movement from pushing through the slope.
    vertical_translation
} else if !climbing && angle_with_floor <= self.min_slope_slide_angle {

The documentation on climbing isn't clear, but my guess is that it's for obstacles too tall for auto-step where the desired behavior is that if the controller continuously pushes into it, it'll eventually rise and move across the surface by alternating between pure vertical movement in climbing mode (when in contact) and standard forward movement (when not in contact). Without this, climbing would result in rapid horizontal movement for steep slopes rather than a climbing effect - the comment also mentions clipping into the ground, but it's not clear how that would occur unless horizontal_translation was returned.

Anyways, the good news to all of this is the fix is simple, just flip the climb angle comparison which appears to have simply been reversed in this commit. So:

if climbing && angle_with_floor <= self.max_slope_climb_angle {

Offset collapse

The other issue I mentioned, the offset collapse, appears unrelated. I dug into this a bit as well. It has usage sprinkled throughout KinematicCharacterController, but this line stood out:

(toi.toi - (-toi.normal1.dot(&translation_dir)) * offset).max(0.0);

I would have expected:

(toi.toi - offset / (-toi.normal1.dot(&translation_dir))).max(0.0);

That improved the issue, but didn't fix it. It also risks weird behavior at ~90 degree angles - which shouldn't trigger a collision anyways, I'm sure there are edge cases with existing penetration, etc.

The full fix seems pretty involved and there's no clear way to do it performantly, at least not in the current setup. In particular the distance given to the shape cast is translation_dist + offset. At glancing angles, this will allow penetration into the offset zone because no hit will be detected. The right thing to do here appears to be to fatten up the controller shape by offset.

Next steps

Given that this is all in rapier3d (rather than bevy_rapier3d) I'll open a task there and submit a PR.

Given the remaining issues and that this behavior has been broken for over a year without anyone seeming to notice, I'd also recommend going with a different character controller for the time being. I ran across bevy-tnua which seems well tested and can layer on top of Rapier.

@believeinlain
Copy link

Thanks for getting to the bottom of this! I applied your fix as a patch in my own project and it completely fixed the issue. Hopefully your PR gets approved and rolled into the next official release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants