游戏中的数学 13:连续碰撞怎样阻止穿墙
终点会撒谎
离散碰撞只检查 Tick 结束位置。物体若在一个 1/60s Tick 内从墙前移动到墙后,终点不重叠,却已经穿过墙。实验把问题限定为一维移动圆与墙,先让失败能被穷举。
// $WORK_DIR/.tmp/game-math-lab/Program.cs
// GameMathLab.SweptCircleWall
static bool SweptCircleWall(
double start, double speed, double dt,
double radius, double wallMin, double wallMax,
out double toi)
{
if (speed <= 0) {
toi = 0;
return start + radius >= wallMin &&
start - radius <= wallMax;
}
var expandedMin = wallMin - radius;
var expandedMax = wallMax + radius;
var t0 = (expandedMin - start) / speed;
var t1 = (expandedMax - start) / speed;
toi = Math.Max(0, t0);
return toi <= dt && t1 >= 0;
}
状态由固定 Tick 拥有:start 是 Tick 开始位置,speed 单位为 m/s,dt 单位为秒,TOI 也用秒。渲染插值只能读取结果,不能改写这组权威输入。
连续检测不等于把 Tick 改成连续时间模拟。它只在一个固定 Tick 内解析最早接触事件,随后仍由离散状态机提交位置。回放只需保存相同输入和 Tick 序号,不必同步平台墙钟,也不会让渲染帧率决定碰撞次数。
扩张形状
移动圆撞墙可以转化为“圆心射线撞扩张后的墙”:墙区间两侧各扩张一个半径,圆心退化为点。首次进入扩张区间的时间是 t0,离开时间是 t1;只有时间区间与 [0,dt] 相交才算本 Tick 命中。
speed<=0 是当前一维模型的显式能力边界:静止时只判断已有重叠,负向移动没有被函数声称支持。完整实现可归一化方向后交换区间,或采用上一章的 slab 形式。比起悄悄返回错误 TOI,暴露有限合同更安全。
// Program.cs · Chapter13 单 Tick 判定
const double dt = 1.0 / 60;
const double radius = .25;
var start = -1.0;
var end = start + speed * dt;
var overlapEnd =
end + radius >= 0 &&
end - radius <= thickness;
var crosses =
start + radius < 0 &&
end - radius > thickness;
var discreteHit = overlapEnd;
var continuousHit = SweptCircleWall(
start, speed, dt, radius,
0, thickness, out _);

扫描失败区域
固定实验组合 20 档速度与 20 档墙厚,共 400 例。速度为 5..100m/s,墙厚为 0.05..1.0m。这不是性能测试,而是覆盖输入空间:热力图中每个格子都可由终点重叠和扫掠区间两个布尔值复算。
// Program.cs · Chapter13 参数扫描
var rows = new List<(double X, double Y, double V)>();
int discreteMiss = 0, continuousMiss = 0;
foreach (var speed in Enumerable.Range(1, 20)
.Select(i => i * 5.0))
foreach (var thickness in Enumerable.Range(1, 20)
.Select(i => i * .05))
{
// overlapEnd、crosses、continuousHit 由上一段计算
var missed = crosses && !discreteHit ? 1 : 0;
if (missed == 1) discreteMiss++;
if (crosses && !continuousHit) continuousMiss++;
rows.Add((speed, thickness, missed));
}
Check(discreteMiss > 0 && continuousMiss == 0,
"continuous sweep catches tunnelling cases");

正常与失败
正常时序是:固定 Tick 提供起点和速度 → 扩张墙体 → 求进入/离开时间 → 取 [0,dt] 内最早 TOI → 移动到接触点 → 用切向速度消耗剩余时间。失败时序是:只算终点 → 高速物体越过整段墙体 → 终点无重叠 → 误报安全。
// Program.cs · Chapter13 接触与切向余量
var movement = MoveCircleAgainstWall(
new Vector2(-1, 0),
new Vector2(100, 20),
1.0 / 60, .25, 0, .1);
var penetration = Math.Max(0,
movement.Position.X + .25);
Check(movement.Hit
&& Math.Abs(movement.Position.X + .25) < 1e-6,
"mover stops at contact boundary");
Check(Math.Abs(movement.Position.Y - 20.0 / 60) < 1e-6,
"remainder moves along tangent");
Check(penetration < 1e-9,
"mover does not penetrate wall");
WriteData(13, new {
cases = rows.Count,
discrete_misses = discreteMiss,
continuous_misses = continuousMiss,
sample_toi_s = movement.Toi,
final_position = V(movement.Position),
remainder_s = movement.RemainingSeconds,
penetration_m = penetration
});
固定样本的 TOI 为 0.0075s:圆心从 -1m 前进到扩张墙边界 -0.25m,路程 0.75m / 100m/s。剩余 0.0091667s 只保留切向速度 20m/s,因此最终位置为 (-0.25,0.333333)m,穿透深度为零。
接触后不能把整个 Tick 的速度再次积分,否则虽检测到 TOI,物体仍会穿透。正确顺序是先消耗 toi,再把剩余 dt-toi 投影到接触切线;若一帧出现多个接触,循环应设最大迭代数,并把预算耗尽记录为可观察失败。
固定运行
运行退出码为 0,15 章全部通过。本章的 400 组输入中,终点检测漏掉 22 组,连续扫掠为 0 组;这只证明当前一维、正速度合同内没有漏判,不外推到旋转凸体。
22 个漏判格集中在“单 Tick 位移大于墙厚与直径组合”的区域,是离散端点测试的结构性盲区。把频率从 60Hz 提高只能缩小该区域,不能消除它;速度上限或墙厚一旦变化,穿墙会再次出现。
cd "$WORK_DIR"
/usr/bin/time -p dotnet run \
--project .tmp/game-math-lab/GameMathLab.csproj \
-- --out .tmp/game-math-lab/out
CH13 PASS cases=400 discrete_misses=22 continuous_misses=0 sample_toi=0.007500s final=(-0.250,0.333)m remainder=0.009167s penetration=0.000E+000m
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0
{
"cases": 400,
"discrete_misses": 22,
"continuous_misses": 0,
"sample_toi_s": 0.0075,
"final_position": { "x": -0.25, "y": 0.3333333 },
"remainder_s": 0.009166666666666667,
"penetration_m": 0
}
移动体合同
连续检测增加了分支与形状扩张成本,却把穿墙从“调小步长碰运气”变成可证明的时间区间。当前最小实现只处理一维墙和正向速度;负速度采用镜像区间或统一 slab 求交,旋转与任意凸体则在真实需求出现时再升级。
本章交付当前一维墙合同内不穿透的固定步长移动体:逻辑层消费 TOI,先走到接触点,再在剩余时间内按接触切线滑动。下一章只读取这个权威世界位置,将它投影到屏幕并从屏幕射线回代,显示层仍无权修正物理状态。