· 5 分钟阅读 · 1871 字
两颗投射物交换了位置却没碰撞:连续检测必须使用相对位移
两颗半径为 1 的球形投射物,从 X 轴的 −5 和 5 出发,在一个物理步内分别移动 10 和 −10。步末,它们交换位置,球心仍相距 10。只比较更新后的包围球会得到“没有接触”,但两条运动轨迹已经在步内穿过彼此。
冻结其中一颗球,再让另一颗球做扫掠,也会改变答案:冻结右球时首次接触比例为 0.8,而双方同时移动时是 0.4。碰撞检测需要回答的是同一物理步、同一时间参数下两个运动体的距离。独立 C# 探针以 System.Numerics.Vector3 保存输入,用双精度计算接触根,验证这一纵向切片;它没有接入引擎求解器,也没有模拟碰撞后的反弹。

把两个运动写进同一个时间参数
设本步归一化时间为 u ∈ [0,1],起点为 a0、b0,完整位移为 da、db。这里位移的单位是世界长度;若上游交付速度,必须先乘同一个步长。球心差满足:
p = a0 - b0
v = da - db
R = ra + rb
A(u) - B(u) = p + u*v
|p + u*v|² = R²
A*u² + 2*B*u + C = 0
A = dot(v,v)
B = dot(p,v)
C = dot(p,p) - R²
v 是相对位移,不能归一化,否则求出的参数就不再是本步时间比例。两个位置和两份位移必须属于同一世界坐标系;从不同快照取起点、混用局部位移与世界位置,都会破坏这个距离方程。半径也必须是这个坐标系里的球半径,非均匀缩放后的椭球不能直接送进此接口。
示例中 p=-10、v=20、R=2,因此 A=400、B=-200、C=96。两个根为 0.4 和 0.6,分别对应进入与离开接触区间。首次根处,两球中心为 −1 和 1。若把目标冻结,v 变成 10,进入根随之变成 0.8;错误来自运动模型,增加采样精度无法修复它。

初始重叠必须先于接近方向判断
SphereSweep.cs 的 FirstContact 先处理初始相交,再检查相对运动。下面是完整核心组件:
using System.Numerics;
public static class SphereSweep
{
public static double? FirstContact(Vector3 a0, Vector3 da, double ra,
Vector3 b0, Vector3 db, double rb)
{
if (!Finite(a0) || !Finite(da) || !Finite(b0) || !Finite(db) ||
!double.IsFinite(ra) || !double.IsFinite(rb) || ra < 0 || rb < 0)
throw new ArgumentException("Invalid sphere motion");
// 在转成双精度后相减,避免中间单精度运算丢失额外精度。
double px = (double)a0.X - b0.X, py = (double)a0.Y - b0.Y;
double pz = (double)a0.Z - b0.Z;
double vx = (double)da.X - db.X, vy = (double)da.Y - db.Y;
double vz = (double)da.Z - db.Z;
double r = ra + rb;
double c = px * px + py * py + pz * pz - r * r;
if (!double.IsFinite(c)) throw new ArgumentException("Range exceeded");
if (c <= 0) return 0;
double a = vx * vx + vy * vy + vz * vz;
double b = px * vx + py * vy + pz * vz;
if (a == 0 || b >= 0) return null;
double d = b * b - a * c;
if (d < 0) return null;
// 用根的乘积计算较小根,减轻两个相近数相减的消去。
double t = c / (-b + Math.Sqrt(d));
return t >= 0 && t <= 1 ? t : null;
}
private static bool Finite(Vector3 v) => float.IsFinite(v.X) &&
float.IsFinite(v.Y) && float.IsFinite(v.Z);
}
C <= 0 返回零,覆盖已有重叠和恰好接触。这个分支必须在 B >= 0 之前:两球已经重叠、随后彼此远离时,本步仍从接触状态开始。把“正在远离”提前当成未命中,会把需要修正穿透的对象从接触集合中删除。
在 C > 0 的前提下,A == 0 表示双方相对静止;B >= 0 表示距离平方从起点开始不再下降。两者都无法在未来进入接触。只有距离正在缩小时才检查判别式 D = B² - A*C,并接受闭区间 [0,1] 内的进入根。
实现用 C / (-B + sqrt(D)) 计算较小根。它由两根乘积 C/A 推出,减少常见公式 (-B-sqrt(D))/A 的相近数相减。由于进入该分支时 C>0、B<0,分母为正。它仍是浮点求根,不提供任意尺度下的精确几何谓词:接近相切时判别式可能受舍入影响,产品若需要接触容差,应把容差作为明确的碰撞外壳或误差合同,再补相应尺度测试,不能随意把所有小负判别式改成零。
输入在读取每个坐标分量后先转换为双精度,再相减。已经存入 Vector3 的单精度位置损失无法恢复;大世界仍需在写入这些输入之前使用局部原点。非法坐标、负半径和非有限半径被拒绝,半径平方超出可表示范围也会抛出异常。接口用 null 表示本步没有接触,用数值零表示初始接触,两者不能合并。
用最近距离复核求根结果
Program.cs 的固定断言覆盖对向穿越、同向等速、初始重叠后远离、初始接触、相切、擦过、恰在步末命中、只在下一步命中、零半径以及非法输入。探针还固定种子 20261021,生成 10,000 组双方位置与位移,各坐标分量位于 [-10,10),两球半径均为 1。
随机校验除交换双方后结果一致外,还独立求距离最小的时刻 clamp(-dot(p,v)/dot(v,v),0,1),检查该处距离是否不大于半径和,以此复核有无接触。对正时间命中,再代回接触方程检查残差。这组检查同时约束漏报、误报和接触点,避免只验证某一个正常样本。
2026-10-21 的 .NET 9 Release 执行命令如下,构建为零警告、零错误,退出码均为 0:
dotnet build "$LAB_DIR/SweepLab.csproj" -c Release --no-restore
dotnet run --project "$LAB_DIR/SweepLab.csproj" -c Release --no-build
{
"passed": 18,
"failed": 0,
"skipped": 0,
"elapsed_ms": 15.1503,
"random_cases": 10000,
"positive_hits": 105,
"max_residual": 2.7533531010703882e-14,
"crossing_t": 0.4,
"a_contact_x": -1,
"b_contact_x": 1,
"endpoint_distance": 10,
"frozen_target_t": 0.8,
"tangent_t": 0.5,
"co_moving": "miss"
}
耗时是当前探针断言与随机检查的运行时间,不是物理引擎吞吐基准。105 个正时间随机命中的最大距离平方残差约为 2.75e-14;该值只对应上述尺度、输入分布与双精度中间计算。
连续检测返回首次接触时间,还没有完成碰撞响应。物理调度器应先收集本步候选对,求最早接触,再将相关运动推进到对应时刻;施加冲量后,剩余时间的速度已经改变,不能继续复用原来按整步位移算出的后续根。大量对象还需要保守覆盖整步轨迹的宽阶段候选,端点包围体漏掉的对象不会进入本求解器。
这套实现适用于恒定半径、步内直线运动的球体。加速轨迹、传送和中途改变半径应分段或改用匹配的连续模型。为投射物增加一个扫掠调用之前,首先要让双方交付同一物理步的起点与位移;否则即使命中根算得完全准确,得到的也是另一个运动问题的答案。