本文属于系列「游戏中的数学:从坐标到可同步运动」第 13 / 15 篇

游戏中的数学 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/sdt 单位为秒,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 _);

Minkowski 扩张、首次接触与切向余量

扫描失败区域

固定实验组合 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,先走到接触点,再在剩余时间内按接触切线滑动。下一章只读取这个权威世界位置,将它投影到屏幕并从屏幕射线回代,显示层仍无权修正物理状态。