Skip to content

Soft-body tearing is O(torn elements × body size): every particle split rescans the whole body #1035

Open

Description

@ahvonenj

Disclaimer

I found this issue with soft body tearing when I was working on some code with Claude. I was working on a physics sandbox and I was slamming a big soft body block at the floor when I saw a rather large hiccup as the soft body block broke.

Claude investigated the issue and noted that the issue was with Rapier and that it could fix the issue with a small patch to the library. I gave Claude the go-ahead and it does seem that Claude managed to fix the problem.

I verified the fix myself by attempting the aforementioned "big soft body block slam" again and the hiccup was completely gone. I also ran the reproduction tests (below) myself with and without the fix and the timings match. I also hand-verified the rapier and commit versions referenced by Claude. The code itself or the quality of the fix I can't verify, unfortunately.

I personally don't know a lot about rust itself and I know that pull requests made by AI are often not very well received, so I decided not to make a pull request, but I still wanted to share the fix because as far as I know, this is a real fix and not slop.

Most of the text below is written by Claude. I have attached patch files that contain the fix. My hope is that someone who understands the library and rust better could take a look and make the pull requests themselves if everything looks good (as far as I'm concerned, I don't need to be given any credit as I haven't really done anything here).

Summary

When a soft body with cells tears through many cells in one step, the tear takes time proportional to (number of torn elements) * (number of cells in the body). On a 45 x 18 grid (1620 triangle cells), a hard yank that tears about 280 elements stalls a single step for about 45 ms in the WASM build (@dimforge/rapier2d-simd-compat 0.21.0). Natively, tearing one 34-cell column takes 27 ms.

Most of that time goes on rebuilding whole-body lookup tables once per split, not on the splits themselves. Indexing the cells by particle for the duration of a tear brings the same tear down to about 1 ms. The resulting topology is identical.

Version

  • rapier2d 0.36.0, commit b716d375 (the 0.21.0 JS release)
  • @dimforge/rapier2d-simd-compat 0.21.0 and canary 0.0.0-b716d37-20260925

Reproduction (native)

// in src/dynamics/soft_body/tearing/tests.rs (uses the module's world_with / family helpers)
#[cfg(feature = "dim2")]
#[test]
#[ignore]
fn big_tear_timing() {
    for (label, columns) in [("1 column", 1usize), ("4 columns", 4), ("all cells", 0)] {
        let builder = SoftBodyBuilder::grid(Vector::new(0.0, 1.5), Vector::new(1.5, 0.6), 45, 18)
            .particle_mass(0.004)
            .cell_model(SoftBodyCellModel::Corotational)
            .surface_collider(ColliderBuilder::ball(0.05));
        let (mut world, handle) = world_with(builder);
        let sb = &world.soft_bodies[handle];
        // The cells whose centroid lies in the chosen vertical bands.
        let cells: Vec<u32> = sb.cells().iter().enumerate()
            .filter(|(_, c)| {
                if columns == 0 { return true; }
                let x = c.vertices.iter().map(|&v| sb.particles()[v as usize].position.x).sum::<Real>() / 3.0;
                (0..columns).any(|k| (x - (-1.0 + 2.0 * k as Real / columns.max(2) as Real)).abs() < 0.035)
            })
            .map(|(i, _)| i as u32)
            .collect();
        let start = std::time::Instant::now();
        let event = world.tear_soft_body(handle, &[], &cells);
        let ms = start.elapsed().as_secs_f64() * 1000.0;
        std::println!("{label}: {} torn, {} bodies, {ms:.1} ms",
            event.map_or(0, |e| e.torn_cells.len()), family(&world, handle).len());
    }
}

cargo test --release --lib big_tear_timing -- --ignored --nocapture in crates/rapier2d:

Tear Torn cells Time
1 column 34 27.6 ms
4 columns 131 112 ms
every cell 844 656 ms

Timing the phases of SoftBodySet::change_topology shows that almost all of it is the crack loop in SoftBody::crack_body:

Phase 1 column 4 columns Every cell
Crack loop 25.7 ms 106 ms 609 ms
split_pinched 0.3 ms 1.3 ms 4.8 ms
Straddler removal 0.03 ms 0.03 ms 0.03 ms
finish_topology_change 0.2 ms 0.2 ms 0.4 ms
Splitting off pieces 0.7 ms 0.9 ms 20 ms
Colliders 0.06 ms 0.09 ms 0.3 ms

Cause

Each torn element opens a crack by splitting one of its particles. Several of the helpers used for a split walk every measure element of the body:

  • SoftBody::reachable_elements (tearing_particle_split.rs), used by opens_without_confetti, builds a facet → elements HashMap of the whole body on every call. The comment there says "a tear is a rare event". It is called once per fan group, for every candidate particle of every torn element. Its BFS stops after min_piece elements (3 cells in 2D), so nearly all of that work is building the map. This is the dominant cost.
  • SoftBody::fan scans every element to find the ones around one particle. It is called by plane_fan for each candidate vertex of a torn cell or edge, and by split_particles for every candidate in split_pinched.
  • measure_element_holds and share_measure_element also scan every element. They are called from current_pair and from the straddler check in split_vertex.

The result is O(k * n) for k torn elements in a body of n cells, with a large constant (hash-map building and a Vec allocation per element). It also means that max_tears_per_step does not bound the cost of a fast yank. It only limits edges, and edges loaded past CATASTROPHIC_STRESS are exempt. On a cell body, the cells torn in a step are never limited.

Proposed fix

Keep a particle → cells index (each list sorted) while crack_body runs, and use it in place of the whole-body scans:

  • crack_body: builds the index when the body has cells, runs the existing body (moved to crack_body_indexed), then drops the index. Cuts and bodies without cells keep the current code paths.
  • fan, measure_element_holds, share_measure_element: read the cells around the particle from the index.
  • reachable_elements: for cells, a BFS over the index. The neighbours of a cell across a facet are the cells around the facet's first vertex that contain all of its vertices. This is the same adjacency as the facet map, so the result is the same (only >= min is compared).
  • split_vertex: keeps the index current. A new copy gets an empty list, and each cell that moves from v to a copy moves between their lists. During a tear, cells are only re-pointed, never added or removed, so this is the only place to update.

The index is a new SoftBody field, cell_index: Option<Vec<Vec<u32>>>. It is serde(skip) and None outside a tear. It could also be a parameter threaded through the tear functions if a field is unwelcome.

Results with the fix, from the same test:

Tear Before After
1 column (34 cells) 27.6 ms 1.1 ms
4 columns (131 cells) 112 ms 2.0 ms
every cell (844 torn) 656 ms 25 ms (crack loop: 609 → 3.2 ms)

The torn cells and the resulting bodies are the same before and after. In the WASM build, the hard yank on the 45 × 18 grid drops from about 45 ms to 4 ms per step, with the same torn elements and pieces. cargo test --release --lib and --tests pass for rapier2d and rapier3d.

The change / fix is attached as a patch against b716d37 (tear-cell-index.patch, about 130 lines), along with the reproduction test (big-tear-timing-test.patch).

big-tear-timing-test.patch
tear-cell-index.patch

Remaining linear scans (not addressed)

split_vertex still loops over all edges (re-pointing edges at v and finding straddlers), all boundary elements, and in 3D all dihedrals. crack_across_edge also loops over all edges to clear the torn edge's impulse. These are plain loops without allocation and did not show up in the profile. An edge index would remove them too, but edges are removed during a tear (straddlers), so it would need lazy deletion.

Activity

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