2D: RuntimeError "unreachable" in World.step() when a ball settles against ConvexPolygon bodies (deterministic repro, 25/25 crashes)
Los mantenedores suelen responder en 1 día
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
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de dimforge/rapier
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
Debug assertion in the incremental solver-contact graph when a ball rolls on a Voxels colliderPosiblemente ocupada @shtse8 la tomó hace 27 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
Typo in the docsAbierto
Dificultad 1/5 Menos de una hora Aptitud para principiantes 90/100
Los mantenedores suelen responder en 1 día
-
Dificultad 3/5 1-2 días Aptitud para principiantes 56/100
Los mantenedores suelen responder en 1 día
-
Dificultad 3/5 1-2 días Aptitud para principiantes 67/100
Los mantenedores suelen responder en 1 día
Todos los issues de dimforge/rapier
Issues similares
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 72/100
Los mantenedores suelen responder en 1 día
-
status:untriaged
Dificultad 1/5 Menos de una hora Aptitud para principiantes 72/100
midnightntwrk/midnight-zk#592 ·
Los mantenedores suelen responder en 3 días
-
QuartoHub: have the Document name before the filename in the browser tabPosiblemente ocupada @cscheid la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
quarto-dev/q2#930 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
muse P3
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
HerringtonDarkholme/hd-lang#923 · 1 comentario ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día