円と線分の当たり判定で球が壁をすり抜ける条件とサブステップ

2D のゲームで当たり判定を自作すると、まず必ずすり抜け(トンネリング)にぶつかる。ゆっくり動いているうちは正しく跳ね返るのに、速くなった瞬間だけ壁の向こう側へ抜ける。しかも再現しにくい。

原因は分かりやすい。多くの実装が「動かしてから、重なっていたら押し戻す」という位置ベースの判定を使っているからだ。1回の更新で壁の厚みより大きく動くと、動かした後の位置がもう壁の向こう側にあり、「重なっていない」と判定される。

この記事では、hashito.biz のローターフォールという落ち物パズルの物理エンジン(public/tools/rotor-fall/engine.js)を題材に、すり抜けが起きる速さを式で出し、実際にエンジンを Node で回して余裕を測るところまでやる。摩擦を1つ触っただけですり抜けが起き、ゲームが成立しなくなった実例がソースに記録されているので、それも一緒に読む。

壁を全部「線分」に潰す

このエンジンの設計は、当たり判定を1種類に絞っている。球は円、壁はすべて線分である。円弧も曲線も、線分の集まりに潰してある。

1
2
3
4
/* 球は円、壁はすべて **線分** で表す。曲線も円弧も «線分の集まり» に潰しているので、
* 当たり判定は「円 対 線分」1種類だけで済む。回転する部品も、毎フレーム角度から
* 端点を計算し直した線分として扱う。**回転する «面» を別扱いにしない** のが要点で、
* 分けると回転体の縁だけ判定が漏れる(実際そうなって球がすり抜けた)。 */

盤面には回転する円盤があり、その内部の通路が回る。回転体を「回転する面」として別の判定式で扱うと、縁のところで判定が抜ける。毎フレーム角度から端点を計算し直して、ただの線分として同じ経路に流すほうが、判定の種類が増えないぶん穴が生まれない。

判定の本体は collide() で、線分ごとに最近接点を取り、接触距離を下回っていたら押し戻す。

1
2
3
4
5
6
7
8
var c = closestOnSegment(b.x, b.y, s.ax, s.ay, s.bx, s.by);
var dx = b.x - c.x, dy = b.y - c.y;
var d2 = dx * dx + dy * dy;
var minD = b.r + WALL_T;
if (d2 >= minD * minD) continue;
...
// めり込みを押し戻す
b.x += nx * (minD - d); b.y += ny * (minD - d);

minD が接触距離である。球の半径 BALL_R = 4.6 と壁の太さの半分 WALL_T = 2.0 を足した 6.6px が、「これ以内なら当たっている」の閾値になる。

回転する壁に当たったときは、その接点の速度を足す。

1
2
3
4
5
var wvx = 0, wvy = 0;
if (s.rotor && s.rotor.spin) {
var rx = c.x - s.rotor.cx, ry = c.y - s.rotor.cy;
wvx = -ry * s.rotor.spin; wvy = rx * s.rotor.spin;
}

これが無いと、回っている円盤が球を「押さない」。溝に入った球がその場に留まって落ちなくなる。剛体の接点速度は角速度と半径ベクトルの外積で、2D なら (-ry * ω, rx * ω) になる。

すり抜け限界の速さを式で出す

ここからが本題である。位置ベースの判定は「動かした後の位置」しか見ない。だから1回の更新での移動量が接触距離を超えると破綻する

更新の粒度はサブステップで決まる。このエンジンは1フレームを5分割している。

1
var SUBSTEPS = 5;        // 1 フレームを割る回数。少ないと速い球が壁を抜ける

step() の中身はこうなっている。

1
2
3
4
5
6
7
8
9
10
11
12
var sub = dt / SUBSTEPS;
for (var s2 = 0; s2 < SUBSTEPS; s2++) {
for (i = 0; i < world.balls.length; i++) {
var b = world.balls[i];
b.vy += GRAVITY * (f.float > 0 ? 0.45 : 1) * sub;
...
b.x += b.vx * sub;
b.y += b.vy * sub;
...
collide(b, segs);
}
}

60fps で SUBSTEPS = 5 なら、1サブステップは 1/300 秒である。すり抜け限界の速さは次で出る。

1
2
3
限界の速さ = 接触距離 × 分割数 × フレームレート
= (BALL_R + WALL_T) × SUBSTEPS × FPS
= 6.6 × 5 × 60 = 1980 px/s

分割しない(SUBSTEPS = 1)と 396 px/s まで落ちる。重力が GRAVITY = 1500 px/s^2 なので、0.26 秒の自由落下で限界を超える計算になる。落ち物ゲームでこれは論外で、サブステップは「あると安心」ではなく「無いと成立しない」部類の対策である。

もうひとつ、step() の冒頭に安全装置がある。

1
dt = Math.min(dt, 1 / 30); // タブ復帰などの大ジャンプで壁を抜けるのを防ぐ

requestAnimationFrame は裏タブで止まるので、タブに戻った瞬間に巨大な dt が来る。**dt の上限を切らないと、1回の更新で数百 px 動いて盤面を突き抜ける。** ここを止めると当然スローモーションになるが、すり抜けて壊れるよりはよい。

速度そのものに上限を置く

サブステップを増やせば限界は上がるが、コストは線形に増える。このエンジンは分割数を5で止めて、かわりに速度に上限を置いて限界の内側に留める方針を取っている。

1
2
3
4
5
var sp2 = b.vx * b.vx + b.vy * b.vy;
if (sp2 > MAX_V * MAX_V) {
var k = MAX_V / Math.sqrt(sp2);
b.vx *= k; b.vy *= k;
}

MAX_V = 1450 px/s である。限界の 1980 px/s に対して 26.8% の余裕がある。ソースのコメントには、この値が机上ではなく事故から決まったことが書いてある。

1
2
3
4
5
6
/* 速さの上限。**摩擦を下げたら壁をすり抜けるようになった。**
*
* 実測では、摩擦 0.88 → 0.972 にした直後に «回さなくても深さ 228m» になった。
* 配線が閉じている円盤を球が通り抜けていたためで、パズルとして成立していなかった。
* 余裕を見て 1450px/s で頭打ちにする(自由落下 1 秒ぶんに相当)。 */
var MAX_V = 1450;

触ったのは摩擦だけである。 FRICTION は接線方向に残る速度の割合で、1 に近いほど摩擦が小さい。0.88 から 0.972 に上げたのは「曲面をなめらかに滑り落ちる」ためだったが、その結果として球が速くなり、すり抜けの限界に届いた。

ここで見逃せないのが、バグの現れ方がクラッシュではなくスコアだったことである。「回さなくても深さ 228m」というのは、プレイヤーが何もしていないのに球が全部の円盤を貫通して落ちていたということだ。例外は出ないし、画面も一見動いている。当たり判定の破綻は「エラー」ではなく「成立しないゲーム」として出てくるので、数字で見ていないと気づけない。

同じ形の記録がもう1つある。円盤の中にアイテムを湧かせるとき、中心に置くと壁の中に入ってしまう。

1
2
3
4
/** **中身を円盤の中心に湧かせてはいけない。** 中心は通路が通っているとは限らず、
* 壁の中に球を置くと押し出しの向きが決まらず、壁をすり抜けて盤面の外へ飛ぶ。
* 実測では抱えていた球が出た瞬間に自由落下へ移り、深さが 15410m まで跳ねた
* (=盤面を突き抜けた)。通路の中心線の «真ん中の点» なら必ず内側にある。 */

押し戻しは「最近接点から外向き」に押す実装なので、完全に壁の内側に置かれた物体は、どちらへ押せばよいか決まらない。位置ベースの判定に共通する弱点で、初期位置を必ず「通路の内側」に取ることで避けている。

実際に試す

Node.js 20 以降があれば依存は要らない。このエンジンは UMD で書かれていて DOM に触らないので、require でそのまま読める。まずファイルを1本取ってくる。

1
2
mkdir -p /tmp/rf && cd /tmp/rf
curl -sO https://hashito.biz/tools/rotor-fall/engine.js

同じディレクトリに tunnel.js を置く。実際にゲームを進めて、球が出す最大の速さと、1サブステップあたりの移動量を測る。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ローターフォールの計算層をそのまま Node で回す。canvas も DOM も要らない。
const E = require("./engine.js");

const FPS = 60, dt = 1 / FPS, SUBSTEPS = 5; // SUBSTEPS は engine.js の定数
const contact = E.BALL_R + E.WALL_T; // 「当たっている」とみなす距離
const limit = (n) => contact * n * FPS; // 分割数 n のときのすり抜け限界

let maxV = 0, maxMove = 0, frames = 0;
const w = E.createWorld({ seed: 20260830, shots: 8 });
E.spawn(w);
while (!w.over && frames < 5400) {
const prev = w.balls.map((b) => ({ x: b.x, y: b.y }));
E.step(w, dt);
frames++;
for (let i = 0; i < w.balls.length && i < prev.length; i++) {
const b = w.balls[i];
maxV = Math.max(maxV, Math.hypot(b.vx, b.vy));
maxMove = Math.max(maxMove, Math.hypot(b.x - prev[i].x, b.y - prev[i].y) / SUBSTEPS);
}
}

console.log("BALL_R =", E.BALL_R, "px / WALL_T =", E.WALL_T, "px / 接触距離 =", contact.toFixed(1), "px");
console.log("分割 1 のときのすり抜け限界:", limit(1).toFixed(0), "px/s");
console.log("分割 5 のときのすり抜け限界:", limit(SUBSTEPS).toFixed(0), "px/s");
console.log("エンジンの速度上限 MAX_V :", E.MAX_V, "px/s");
console.log("--");
console.log("回した frames :", frames);
console.log("実測の最大速度 :", maxV.toFixed(1), "px/s");
console.log("実測の1サブステップ最大移動:", maxMove.toFixed(2), "px(接触距離の " + ((maxMove / contact) * 100).toFixed(0) + "%)");
console.log("到達スコア :", E.scoreOf(w), "m");

実行する。

1
node tunnel.js

出力はこうなる。

1
2
3
4
5
6
7
8
9
BALL_R = 4.6 px / WALL_T = 2 px / 接触距離 = 6.6 px
分割 1 のときのすり抜け限界: 396 px/s
分割 5 のときのすり抜け限界: 1980 px/s
エンジンの速度上限 MAX_V : 1450 px/s
--
回した frames : 630
実測の最大速度 : 499.2 px/s
実測の1サブステップ最大移動: 1.64 px(接触距離の 25%)
到達スコア : 12 m

読みどころは2つある。

1つ目。実測の最大速度 499.2 px/s は、分割 1 のときの限界 396 px/s をすでに超えている。 つまり「サブステップを入れなければ、このゲームは普通に遊んでいるだけですり抜ける」。理屈の上の話ではなく、既定の遊び方で到達する速度である。

2つ目。1サブステップの移動量は最大でも接触距離の 25% に収まっている。 実測の余裕は、上限 MAX_V から計算した 26.8% よりさらに大きい。実際には壁や円盤に当たって減速するので、MAX_V に張り付くことは滅多にない。上限は「普段の値」ではなく「最悪でもここまで」を押さえるためのものである。

このエンジンは乱数を Math.random ではなく種付きの線形合同法で持っているので、同じ seed なら出力は毎回同じになる。上のスクリプトを2回走らせて差分を取れば一致するはずで、これが「テストが書ける物理」の条件でもある。

1
2
3
4
world.rand = function () {
world.seed = (world.seed * 1103515245 + 12345) & 0x7fffffff;
return world.seed / 0x7fffffff;
};

自分のエンジンで確かめるなら、次の3つの数字を出せばよい。

  1. 接触距離(球の半径+壁の厚みの半分)
  2. 1サブステップの実移動量の最大値
  3. その比

比が 100% に近づいているなら、分割数を増やすか速度に上限を置くかのどちらかが要る。100% を超えたところですり抜けが始まる。

まとめ

  • 位置ベースの当たり判定は、1回の更新の移動量が「球の半径+壁の厚み」を超えると破綻する
  • 限界の速さは 接触距離 × 分割数 × FPS。実測では 6.6px × 5 × 60 = 1980 px/s
  • 分割しないと 396 px/s まで落ちる。重力 1500 px/s^2 なら 0.26 秒の落下で超えるので、サブステップは必須
  • サブステップを増やすとコストが線形に増える。速度に上限を置いて限界の内側に留めるほうが安い(このエンジンは 1450 px/s、余裕 26.8%)
  • dt にも上限を切る。裏タブから戻った瞬間の大きな dt が同じ破綻を起こす
  • 押し戻しは「最近接点から外向き」なので、壁の内側に物体を置くと向きが決まらない。初期位置は必ず通路の内側に取る
  • 当たり判定の破綻はエラーではなくスコアとして現れる。摩擦を 0.88 → 0.972 に変えただけで「回さなくても 228m」になった記録がソースに残っている

エンジンが動いているところは ローターフォールで遊べる。ほかのゲームは hashito.biz のゲーム一覧にある。本記事の数値と出力例は Node.js 20.17.0 で実際に実行した結果である。


円と線分の当たり判定で球が壁をすり抜ける条件とサブステップ
https://blog.hashito.biz/2026/08/30/circle-segment-collision-tunneling-substeps/
著者
hashito
作成日
2026年8月30日
著作権