Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

2D: RuntimeError "unreachable" in World.step() when a ball settles against ConvexPolygon bodies (deterministic repro, 25/25 crashes)

Abierto
#985 3 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

@Bardakor ya está trabajando en esto.

Desde el 16/8/2026.

  • #996 de @Bardakor — abierto

Evaluación

Dificultad
5/5
Tiempo estimado
Más de una semana
Aptitud para principiantes
45/100
Tipo de issue
Error
Claridad
Bastante claro
Estado de actividad
Activo
Stack tecnológico
javascript, rust, wasm
Área
game-dev

Línea de trabajo

Empieza ejecutando el repro.cjs proporcionado con @dimforge/rapier2d-compat en los modos sharp, round002 y round008; después inspecciona la ruta de World.step() implicada por la pila de wasm. Compara los casos de ConvexPolygon con el control cuboid y la secuencia de fragmentación; se considera terminado cuando las variantes deterministas se completan sin RuntimeError, mientras los colliders de polígonos siguen siendo compatibles.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

Environment

Package @dimforge/rapier2d-compat
Versions reproduced 0.20.0 (latest stable) and canary 0.0.0-5de07a4-20260808 (different wasm hash, identical panic ticks)
Runtime Node.js v24.15.0, Windows 11 x64, single-threaded
Settings All defaults except timestep = 1/60, lengthUnit = 1.0, numSolverIterations = 4, gravity (0, -20)

Summary

World.step() panics with RuntimeError: unreachable (uncatchable — the world is unusable afterwards) when a ball rests on / grinds against ConvexPolygon bodies in a small settling pile. The panic occurs tens of ticks after the initial impact, while the pile is settling.

The repro below is ~100 lines of plain rapier calls, fully deterministic, no external assets. It runs 25 parameter variants (ball density ×0.8–1.2, five drop positions) per shape mode:

Shape mode Crashes
convexHull (sharp) 25/25 (panic at tick 25–53)
roundConvexHull, radius 0.02 24/25 (tick 36–80)
roundConvexHull, radius 0.08 25/25 (tick 42–73)
same silhouette approximated with cuboids 0/25

Reproduction

npm i @dimforge/[email protected], save as repro.cjs, then node repro.cjs sharp (or round002 / round008):

repro.cjs
// Standalone repro: `unreachable` panic in rapier2d-compat 0.20.0 narrow phase.
// Run: node tmp-repro.cjs [sharp|round002|round008]
const RAPIER = require('@dimforge/rapier2d-compat');

const FOOT_L = [-1.5, 0, -1.1, 0, -0.55, 1.4, -0.95, 1.4];
const FOOT_R = [1.5, 0, 1.1, 0, 0.55, 1.4, 0.95, 1.4];
const LINTEL = [-1.2, 1.4, 1.2, 1.4, 1.2, 2.0, -1.2, 2.0];
const PARTS = [FOOT_L, FOOT_R, LINTEL];
const RELEASE = [
  { vx: 3, w: -1.5 },
  { vx: -3, w: 1.5 },
  { vx: 0, w: 0 },
];

function shrink(points, margin) {
  const n = points.length / 2;
  let cx = 0, cy = 0;
  for (let i = 0; i < n; i += 1) { cx += points[i * 2]; cy += points[i * 2 + 1]; }
  cx /= n; cy /= n;
  const out = [];
  for (let i = 0; i < n; i += 1) {
    const dx = points[i * 2] - cx, dy = points[i * 2 + 1] - cy;
    const len = Math.hypot(dx, dy);
    const k = len > margin ? (len - margin) / len : 0;
    out.push(cx + dx * k, cy + dy * k);
  }
  return out;
}

function hullDesc(points, radius) {
  const desc = radius > 0
    ? RAPIER.ColliderDesc.roundConvexHull(new Float32Array(shrink(points, radius)), radius)
    : RAPIER.ColliderDesc.convexHull(new Float32Array(points));
  return desc.setDensity(1).setFriction(0.6).setRestitution(0);
}

function run(ballDensity, hitX, radius) {
  const world = new RAPIER.World({ x: 0, y: -20 });
  world.timestep = 1 / 60;
  world.lengthUnit = 1.0;
  world.numSolverIterations = 4;
  const events = new RAPIER.EventQueue(true);

  const ground = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(15, -0.5));
  world.createCollider(
    RAPIER.ColliderDesc.cuboid(30, 0.5).setFriction(0.6).setRestitution(0),
    ground,
  );

  // Fixed composite "arch": two slanted feet + rectangular lintel.
  const arch = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(6, 0));
  const archColliders = PARTS.map((pts) =>
    world.createCollider(
      hullDesc(pts, radius).setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS),
      arch,
    ),
  );
  const archHandles = new Set(archColliders.map((c) => c.handle));

  const ball = world.createRigidBody(
    RAPIER.RigidBodyDesc.dynamic().setTranslation(hitX, 3.5).setLinvel(2, -12),
  );
  const ballCollider = world.createCollider(
    RAPIER.ColliderDesc.ball(0.25).setDensity(ballDensity).setRestitution(0),
    ball,
  );

  let fragmented = false;
  for (let i = 0; i < 600; i += 1) {
    const prevVx = ball.linvel().x;
    try {
      world.step(events);
    } catch (err) {
      return i; // panic tick
    }
    let hit = false;
    events.drainCollisionEvents((h1, h2, started) => {
      if (!started) return;
      const pair = [h1, h2];
      if (pair.includes(ballCollider.handle) && pair.some((h) => archHandles.has(h))) hit = true;
    });
    if (hit && !fragmented) {
      fragmented = true;
      // Replace the composite with three dynamic fragments at identical
      // world transforms; lintel gets 60 % of the ball's horizontal momentum.
      const t = arch.translation();
      const rot = arch.rotation();
      world.removeRigidBody(arch);
      const ballMomentumX = ball.mass() * prevVx;
      PARTS.forEach((pts, k) => {
        const body = world.createRigidBody(
          RAPIER.RigidBodyDesc.dynamic()
            .setTranslation(t.x, t.y)
            .setRotation(rot)
            .setLinvel(RELEASE[k].vx, 0)
            .setAngvel(RELEASE[k].w),
        );
        world.createCollider(hullDesc(pts, radius), body);
        if (k === 2) {
          body.setLinvel({ x: (0.6 * ballMomentumX) / body.mass(), y: 0 }, true);
        }
      });
    }
  }
  return null;
}

async function main() {
  await RAPIER.init();
  const mode = process.argv[2] ?? 'round002';
  const radius = mode === 'round002' ? 0.02 : mode === 'round008' ? 0.08 : 0;
  let runs = 0, panics = 0;
  const ticks = [];
  for (const dScale of [0.8, 0.9, 1.0, 1.1, 1.2]) {
    for (const hitX of [5.7, 5.9, 6.0, 6.1, 6.3]) {
      runs += 1;
      const panicTick = run(8.0 * dScale, hitX, radius);
      if (panicTick !== null) { panics += 1; ticks.push(panicTick); }
    }
  }
  console.log(`${mode}: ${panics}/${runs} panicked${ticks.length ? ` (ticks: ${ticks.join(', ')})` : ''}`);
}

main();

The scene: a fixed ground cuboid; a fixed composite body with three convexHull colliders (two slanted parallelogram "feet" and a rectangular "lintel" forming an arch); a ball (r 0.25, restitution 0) dropped onto the lintel with velocity (2, −12). On the first ball↔arch contact event the composite is replaced by three dynamic bodies with the same shapes at identical world transforms and small deterministic release velocities. The ball and the three polygon bodies then settle as a pile on the ground — and world.step() hits unreachable some 25–80 ticks in.

Stack (wasm offsets, 0.20.0 compat build):

RuntimeError: unreachable
  wasm:/wasm/005ab5b2:1:1260375
  wasm:/wasm/005ab5b2:1:1403970
  wasm:/wasm/005ab5b2:1:1404031
  wasm:/wasm/005ab5b2:1:491284
  wasm:/wasm/005ab5b2:1:221623
  wasm:/wasm/005ab5b2:1:679662
  N.stepWithEvents (rapier.mjs)

Additional observations (from a larger game harness, same scene)

  • The ball is required. Removing the ball right after the composite is fragmented: 0/25 crashes in a configuration that otherwise crashes 22/25.
  • Polygon shapes are required. With the three polygons replaced by cuboid approximations of the same silhouette we have never seen a panic (0/25 in every configuration, and none across the rest of the project's physics suites, which are cuboid/ball-only).
  • Not stiffness-related. Reproduces at the default contact_natural_frequency (the table above) and at 120 (there sharp hulls crash less often, 1/25, while round ones still crash 22/25).
  • The trigger state is not captured by snapshots. World.restoreSnapshot(world.takeSnapshot()) taken on the step before the panic does not panic when stepped; a fresh world built with the exact panic-frame poses/velocities does not panic either. The trigger seems to live in non-serialized state (contact workspace / warm-start?).
  • Identical panic ticks on the 0.20.0 stable wasm and the 2026-08-08 canary wasm.

Workaround

Model colliders as cuboids/balls only (avoiding ConvexPolygon/RoundConvexPolygon) — no crashes in any configuration.

Lenguaje dominante
Rust
Estrellas
5.9k
Forks
410
Merge medio
3 h 59 min
PR fusionados (30 d)
10

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de dimforge/rapier

Todos los issues de dimforge/rapier

Issues similares

Más issues de Rust

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.