Skip to content

KCC: panic in solve_single_character_collision_impulse when pushing a collider whose parent body isn't registered yet (0.35) #1009

Open

Description

@nazgull08

Summary

KinematicCharacterController panics inside
solve_single_character_collision_impulse (rapier3d 0.35.0-glamx0.2,
via bevy_rapier3d 0.36) when the character moves into a dynamic collider
during the first frames after world spawn — before the collider's parent
rigid body is fully registered.

Two distinct panics from the same code path, depending on timing:

rapier3d-0.35.0-glamx0.2/src/control/character_controller.rs:957:
called Option::unwrap() on a None value

rapier3d-0.35.0-glamx0.2/src/control/character_controller.rs:944:
range start index 1 out of range for slice of length 0

Line 957 is manifold.data.rigid_body2.unwrap() — None when the
manifold's collider has no parent body attached (yet).

Reproduction (bevy_rapier context)

  • Spawn a level containing RigidBody::Dynamic props near the player
    spawn point (pushable crates/barrels).
  • Spawn a KinematicCharacterController and feed it a translation
    on the first frames (i.e. the player moves immediately).
  • bevy_rapier registers colliders and bodies in separate systems, so for
    a few frames a dynamic prop's collider exists in the pipeline while its
    body attachment doesn't. If the KCC contacts it in that window and
    enters the push-dynamic-bodies path → panic.
  • 100% reproducible by moving immediately after spawn; never reproduces
    if you wait ~1 s (registration completes).

A pure-rapier repro should reduce to: call
solve_character_collision_impulses with a manifold against a collider
whose parent() is None (standalone collider on the character's path).

Expected

Manifolds whose collider has no parent body should be skipped (nothing
to apply an impulse to) instead of unwrap()-ing. rapier 0.32's
character controller handled this case gracefully — the panic is a
regression of the 0.35 KCC rework.

Workaround

Pin to the previous core (bevy_rapier 0.35 / rapier 0.32) — no panic.

Environment

  • rapier3d 0.35.0-glamx0.2 (via bevy_rapier3d 0.36.0), Bevy 0.19.1
  • Linux x86_64, occurs in both debug and with default KCC settings
    (slide: true, snap_to_ground, autostep; the rest default)

Happy to submit a PR — the fix looks like skipping manifolds whose collider has no parent body instead of unwrapping. Let me know if that approach sounds right.

Activity

  1. mcp-tool-shop commented on Sep 26, 2026

    @mcp-tool-shop

    Both panics here reproduce in plain rapier without an unregistered body, and #1004 (released in 0.36.0) fixes both.

    Why an unregistered body is not the cause. In 0.35.0 through 0.35.3, solve_single_character_collision_impulse skips any collider whose parent is not a registered dynamic body before it computes contacts (character_controller.rs:930-932). So a collider without a body should not reach :944 or :957.

    What reaches those lines. They are reached through one manifolds Vec shared by every dynamic collider in range. The loop assumes contact_manifolds only appends to it, and parry does not.

    • Plain convex shapes: parry's default dispatcher pushes a manifold only when the Vec is empty; otherwise it computes into manifolds[0] (parry 0.30.2, default_query_dispatcher.rs:740-751). A second plain dynamic collider therefore silently overwrites the first one's manifold, which stays tagged with the first body. There is no panic, but that body is pushed at the wrong points. That is the "incorrect computation" case Fix character controller manifold collection #1004 mentions.
    • Composite shapes: parry replaces the Vec's contents. This was measured with a compound. For heightfield (contact_manifolds_heightfield_shape.rs:119, mem::take) and trimesh (contact_manifolds_trimesh_shape.rs:125, mem::swap) it is from the source.

    When a compound collider is visited after another dynamic collider:

    • if none of its parts is within the prediction distance, the Vec comes back shorter than before, and manifolds[prev_manifolds_len..] panics at :944;
    • if its parts are in range, its manifolds land below prev_manifolds_len and are never tagged with a body, so rigid_body2.unwrap() panics at :957.

    Repro. Tested with rapier3d-f64 0.35.3 (default features) and a compound. The reported version, 0.35.0-glamx0.2, was not checked, but its panic lines, 944 and 957, match 0.35.3. The scene is a dynamic box in front of a character, with a dynamic compound whose two parts sit gap from the character's sides:

    use rapier3d_f64::control::{CharacterLength, KinematicCharacterController};
    use rapier3d_f64::prelude::*;
    
    /// A dynamic box in front of the character, then a dynamic compound whose two parts sit
    /// `gap` from the character's sides.
    fn push(gap: f64) {
        let dt = 1.0 / 64.0;
        let mut world = PhysicsWorld::new();
        world.gravity = Vector::ZERO;
        world.insert(RigidBodyBuilder::dynamic(), ColliderBuilder::cuboid(0.12, 0.2, 0.12));
        let z = 0.25 + gap + 0.12;
        world.insert(
            RigidBodyBuilder::dynamic().translation(Vector::new(-0.379, 0.0, 0.0)),
            ColliderBuilder::compound(vec![
                (Pose::from_translation(Vector::new(0.0, 0.0, z)), SharedShape::cuboid(0.12, 0.1, 0.12)),
                (Pose::from_translation(Vector::new(0.0, 0.0, -z)), SharedShape::cuboid(0.12, 0.1, 0.12)),
            ]),
        );
        world.step(); // fills the broad phase; nothing moves
        let controller = KinematicCharacterController {
            offset: CharacterLength::Absolute(0.01),
            snap_to_ground: None,
            ..Default::default()
        };
        let shape = SharedShape::cuboid(0.25, 0.25, 0.25);
        let pos = Pose::from_translation(Vector::new(-0.379, 0.0, 0.0));
        let mut collisions = vec![];
        let query = world.broad_phase.as_query_pipeline(
            world.narrow_phase.query_dispatcher(),
            &world.bodies,
            &world.colliders,
            QueryFilter::new(),
        );
        controller.move_shape(dt, &query, &*shape, &pos, Vector::new(dt, 0.0, 0.0), |c| collisions.push(c));
        let PhysicsWorld { broad_phase, narrow_phase, bodies, colliders, .. } = &mut world;
        let mut query =
            broad_phase.as_query_pipeline_mut(narrow_phase.query_dispatcher(), bodies, colliders, QueryFilter::new());
        controller.solve_character_collision_impulses(dt, &mut query, &*shape, 1.0, &collisions);
        println!("gap {gap}: no panic");
    }
    
    fn main() {
        let gap: f64 = std::env::args().nth(1).and_then(|a| a.parse().ok()).unwrap_or(0.1);
        push(gap);
    }

    With gap 0.1, where the parts are outside the 0.06 prediction distance but the compound's AABB spans the character (path shortened):

    thread 'main' (23488) panicked at .../rapier3d-f64-0.35.3/src/control/character_controller.rs:944:48:
    range start index 1 out of range for slice of length 0
    

    With gap 0.01 (path shortened):

    thread 'main' (35452) panicked at .../rapier3d-f64-0.35.3/src/control/character_controller.rs:957:57:
    called `Option::unwrap()` on a `None` value
    

    Results.

    • On 0.36.0 both runs print no panic.
    • The panic depends on which collider is visited first: with the compound inserted before the box, the gap 0.1 case does not panic on 0.35.3.
    • A collider with no parent body next to the character does not panic on either version.

    What this means for this issue. If the pushed props include a compound, trimesh or heightfield collider, 0.36.0 should fix this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions