dimforge / rapier

2D and 3D physics engines focused on performance.
https://rapier.rs
Apache License 2.0
3.93k stars 244 forks source link

Offset of KinematicCharacterController is not sustained when desired translation points partially towards ground #418

Closed kristiansvalland closed 4 months ago

kristiansvalland commented 1 year ago

Hi, this is the first time I am posting an issue in this repo. I have encountered something I believe is a minor bug in the way the KinematicCharacterController updates the transformation under certain circumstances. I am using the bevy 3D plugin.

Situation

In the code below demonstrating the problem, we have the following sequence of actions

  1. The "character" spawns some height above the ground and due to gravity falls towards the ground
  2. The character eventually reaches the ground, upon which is rests with the expected offset off from the ground.
  3. After a delay of 2 seconds, a horizontal component is added to the speed of the character.
  4. Together with gravity, which continuously tries to add a vertical component, the desired translation points partially down towards the ground.
  5. This eventually leads to the character moving slightly towards the ground, now with it's "offset layer" overlapping with the ground.
  6. Not shown in this example, this leads to the character intermittently becoming stuck.

The demonstration of the problem is printed via warn messages in the read_result_system function.

Why it matters

As far as I understand from the documentation, and also based on experience from moving the character around, when the offset is too small, the character can inexplicably get stuck.

Example code

use bevy::prelude::*;
use bevy_rapier3d::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugin(RapierPhysicsPlugin::<NoUserData>::default())
        .add_startup_system(setup_physics)
        .add_system(gravity_movement_system)
        .add_system_to_stage(CoreStage::PostUpdate, read_result_system)
        .add_system_to_stage(CoreStage::PostUpdate, reconcile_character_velocity)
        .run();
}

fn setup_physics(mut commands: Commands) {
    /* Create the ground. */
    commands
        .spawn()
        .insert(Collider::cuboid(20.0, 20.0, 0.1))
        .insert_bundle(TransformBundle::from_transform(Transform::from_xyz(
            0.0, 0.0, -0.1,
        )));

    // Add KinematicCharacterController controlled body with a capsule shape
    commands
        .spawn()
        .insert(RigidBody::KinematicPositionBased)
        // Capsule of total height 2.0
        .insert(Collider::capsule_z(0.7, 0.3))
        .insert(KinematicCharacterController {
            offset: CharacterLength::Relative(0.01),
            up: Vec3::Z,
            ..default()
        })
        .insert(MySpeed::default())
        .insert_bundle(TransformBundle::from(Transform::from_xyz(0.0, 0.0, 2.0)));
}

/// MySpeed controls the speed of our entity because we are responsible for doing that ourselves with the KinematicCharacterController
#[derive(Debug, Component, Default)]
struct MySpeed(Vec3);

fn gravity_movement_system(
    mut query: Query<(&mut MySpeed, &mut KinematicCharacterController)>,
    time: Res<Time>,
) {
    for (mut speed, mut controller) in &mut query {
        speed.0 -= 9.81 * Vec3::Z * time.delta_seconds();

        // Start moving after we have hit the ground
        if time.seconds_since_startup() > 2.0 {
            speed.0.x = 1.0;
        }

        controller.translation = Some(time.delta_seconds() * speed.0);
    }
}

/// Reconcile the actual velocity to that computed by the update step.
///
/// It basically just resets the Up/down (Z) component when it hits the ground.
fn reconcile_character_velocity(
    mut controllers: Query<(&mut MySpeed, &KinematicCharacterControllerOutput)>,
    time: Res<Time>,
) {
    for (mut speed, output) in &mut controllers {
        if output.grounded {
            speed.0 = output.effective_translation / time.delta_seconds();
        }
    }
}

fn read_result_system(controllers: Query<(&Transform, &KinematicCharacterControllerOutput)>) {
    for (transform, output) in &controllers {
        // if less then half of capsule height + offset (1 + 0.02) minus a threshold, warn the user
        if transform.translation.z < 1.02 - 1e-5 {
            warn!(effective_translation = ?output.effective_translation, desired_translation = ?output.desired_translation, ?transform.translation, "offset is gone")
        } else {
            info!(effective_translation = ?output.effective_translation, desired_translation = ?output.desired_translation, ?transform.translation, "ok")
        }
    }
}

The output from this code is something like this while falling and then hitting the ground

2022-11-16T12:47:40.158284Z  INFO repotest: ok effective_translation=Vec3(0.0, 0.0, -0.069549195) desired_translation=Vec3(0.0, 0.0, -0.069549195) transform.translation=Vec3(0.0, 0.0, 1.0903841)
2022-11-16T12:47:40.175001Z  INFO repotest: ok effective_translation=Vec3(0.0, 0.0, -0.070384145) desired_translation=Vec3(0.0, 0.0, -0.07196561) transform.translation=Vec3(0.0, 0.0, 1.02)
2022-11-16T12:47:40.192101Z  INFO repotest: ok effective_translation=Vec3(0.0, 0.0, -7.450581e-9) desired_translation=Vec3(0.0, 0.0, -0.06902031) transform.translation=Vec3(0.0, 0.0, 1.02)
2022-11-16T12:47:40.207368Z  INFO repotest: ok effective_translation=Vec3(0.0, 0.0, -7.450581e-9) desired_translation=Vec3(0.0, 0.0, -0.0027409683) transform.translation=Vec3(0.0, 0.0, 1.02)
2022-11-16T12:47:40.231131Z  INFO repotest: ok effective_translation=Vec3(0.0, 0.0, -7.450581e-9) desired_translation=Vec3(0.0, 0.0, -0.0025370806) transform.translation=Vec3(0.0, 0.0, 1.02)

Then after a short while when the horizontal component is added to the speed:

2022-11-16T12:47:41.043035Z  INFO repotest: ok effective_translation=Vec3(0.0, 0.0, -7.450581e-9) desired_translation=Vec3(0.0, 0.0, -0.0027751853) transform.translation=Vec3(0.0, 0.0, 1.02)
2022-11-16T12:47:41.070448Z  WARN repotest: offset is gone effective_translation=Vec3(0.028393382, 0.0, -0.00790868) desired_translation=Vec3(0.028393382, 0.0, -0.00790868) transform.translation=Vec3(0.028393382, 0.0, 1.0120913)
2022-11-16T12:47:41.083469Z  WARN repotest: offset is gone effective_translation=Vec3(0.013068681, 3.926652e-12, -0.007701233) desired_translation=Vec3(0.013068681, 0.0, -0.005315598) transform.translation=Vec3(0.041462064, 3.926652e-12, 1.00439)

As you can see, as soon as the horizontal component is added, it actually starts moving a little bit down towards the ground as well, despite having hovered above the ground by the offset for a while.

Possible solution

I must admit that I just started using rapier and bevy, and I have barely read the source code of the KinematicCharacterController, so I could be wrong in what follows, However, I will give my input on what I think could be wrong, but leave it to you to conclude.

If for some reason the shape of the character is closer to the ground then the offset (perhaps due to rounding errors or a consequence of previous computations), https://github.com/dimforge/rapier/blob/c600549aacbde1361eba862b34a23f63d806d6a9/src/control/character_controller.rs#L342 will be a no-op due to offset being larger than hit.toi, so (hit.toi - offset).max(0.0) will be zero.

If I understand this correctly, this means that the snap_to_ground method will only be able to snap the character to the ground by attraction, not repulsion, and is thus not able to restore the offset whenever the character gets too close.

Environment

janhohenheim commented 1 year ago

See the linked PR for a jittering issue that popped up when I tried fixing this.