2D: RuntimeError "unreachable" in World.step() when a ball settles against ConvexPolygon bodies (deterministic repro, 25/25 crashes)
Maintainer thường phản hồi trong vòng 1 ngày
Đánh giá
- Độ khó
- 5/5
- Thời gian dự kiến
- Hơn một tuần
- Mức phù hợp với người mới
- 45/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Khá rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- javascript, rust, wasm
- Lĩnh vực
- game-dev
Hướng nghiên cứu
Bắt đầu bằng cách chạy repro.cjs được cung cấp với @dimforge/rapier2d-compat ở các chế độ sharp, round002 và round008, sau đó kiểm tra đường đi World.step() được chỉ ra bởi wasm stack. So sánh các trường hợp ConvexPolygon với trường hợp đối chứng cuboid và chuỗi phân mảnh; được xem là hoàn tất khi các biến thể xác định hoàn thành mà không có RuntimeError, đồng thời các polygon collider vẫn được hỗ trợ.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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.
- Ngôn ngữ chính
- Rust
- Star
- 6k
- Fork
- 421
- Merge trung bình
- 6 giờ 11 phút
- Pull request đã merge (30 ngày)
- 4
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của dimforge/rapier
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Debug assertion in the incremental solver-contact graph when a ball rolls on a Voxels colliderCó thể đã có người làm @shtse8 đã nhận 29 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Typo in the docsĐang mở
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 90/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 1-2 ngày Mức phù hợp với người mới 48/100
dimforge/rapier#1053 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của dimforge/rapier
Issue tương tự
-
Add a just recipe that runs the workflow linters locallyCó thể đã có người làm @sgamelin đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
-
good first issue security
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 64/100
-
Status bar: a progress line that keeps the same text (import at 0%) ages out after 8 s and blinksĐang mởbug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
transit-audio/transit#216 ·
-
area: effects devices enhancement
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
suhina-daw/suhina-daw#1060 ·
Maintainer thường phản hồi trong vòng 1 ngày