游戏中的数学 11:距离与最近点是碰撞的地基
先问距离
角色是否碰墙不是最先该问的问题。更稳定的接口先返回“查询点离形状多远、最近位置在哪里”;相交只是距离跨过零的一个分支。实验工程把线段最近点压缩成一个纯函数,输入和输出都使用米。
// $WORK_DIR/.tmp/game-math-lab/Program.cs
// GameMathLab.ClosestPointSegment
static Vector2 ClosestPointSegment(
Vector2 p,
Vector2 a,
Vector2 b,
out float t)
{
var ab = b - a;
var d = ab.LengthSquared();
if (d < 1e-12) { t = 0; return a; }
t = Math.Clamp(Vector2.Dot(p - a, ab) / d, 0, 1);
return a + t * ab;
}
状态所有权很小:调用者拥有查询点与形状,函数只计算结果,不缓存上一帧。这样物理 Tick、编辑器吸附和导航查询可以复用同一合同,也不会把渲染帧状态带进碰撞判定。
距离比较时应优先使用平方距离,真正需要法线、穿透深度或展示数值时再开方。最近点函数本身不做“是否碰撞”的阈值判断,因为皮肤宽度、角色半径和编辑器吸附半径属于不同上层策略。几何层若内置任意 epsilon,三个调用者会得到无法解释的边界。
投影再截断
无限直线上的投影参数为 dot(p-a, b-a) / |b-a|²。线段多出的约束只有 t∈[0,1]:区间内返回投影点,区间外返回端点。固定输入 a=(-2,0)、b=(3,1)、p=(1,4) 得到 t=0.7307692,最近点约为 (1.653846, 0.730769)。
// Program.cs · Chapter11 正常路径
var a = new Vector2(-2, 0);
var b = new Vector2(3, 1);
var p = new Vector2(1, 4);
var closest = ClosestPointSegment(p, a, b, out var t);
Check(t >= 0 && t <= 1,
"closest point parameter is clamped");

图中的垂足不是概念标记,而是上述固定输入的数值结果。碰撞法线可由 normalize(p-closest) 得到;若距离为零,则必须由形状特征或上一接触法线消除方向歧义,不能偷偷归一化零向量。
t 还保留了命中特征:零对应端点 A,一对应端点 B,中间值对应线段内部。接触求解器可据此选择端点或侧面法线,不必再用坐标误差反猜。这个参数无单位,不能与下一章以米计的射线 t 混用。
符号保存内外
圆的有符号距离 |q|-r 在外部为正、边界为零、内部为负。实验在 [-4,4]×[-3,3] 上以 0.1m 间距取样,共得到 4941 个点;这张等高线图因此可以从数据重建,而不是装饰图。
// Program.cs · Chapter11 距离场与边界断言
var field = new List<(double X, double Y, double V)>();
for (var y = -30; y <= 30; y++)
for (var x = -40; x <= 40; x++)
{
var q = new Vector2(x / 10f, y / 10f);
field.Add((q.X, q.Y, q.Length() - 2));
}
var boundaryResidual = Enumerable.Range(0, 360).Max(i =>
Math.Abs(new Vector2(
(float)(2 * Math.Cos(DegToRad(i))),
(float)(2 * Math.Sin(DegToRad(i)))).Length() - 2));
Check(boundaryResidual < 1e-6,
"circle SDF is zero on boundary");

退化必须显式
失败时序是:端点重合 → |b-a|² 接近零 → 普通除法产生无意义参数 → 最近点污染后续法线。实现提前返回端点,并把参数固定为零。这里不是“容错猜测”,而是为零长度线段定义确定语义。
// Program.cs · Chapter11 退化回归
var degenerate = ClosestPointSegment(p, a, a, out var td);
var aabbDistance = PointAabbDistance(
new Vector2(4, 5), new(-1, -2), new(2, 3));
var sphereInside = SphereSignedDistance(
new Vector3(0, 0, 1), Vector3.Zero, 2);
Check(Vector2.Distance(degenerate, a) < 1e-6 && td == 0,
"degenerate segment returns endpoint");
Check(Math.Abs(aabbDistance - Math.Sqrt(8)) < 1e-6,
"point-AABB distance matches corner distance");
Check(Math.Abs(sphereInside + 1) < 1e-6,
"sphere signed distance is negative inside");
WriteData(11, new {
grid_samples = field.Count,
closest = V(closest),
segment_t = t,
degenerate_t = td,
point_aabb_distance_m = aabbDistance,
sphere_inside_distance_m = sphereInside,
boundary_max_residual_m = boundaryResidual
});
void Done(int chapter, string data)
{
passed++;
Console.WriteLine($"CH{chapter:00} PASS {data}");
}
void WriteData(int chapter, object value)
{
File.WriteAllText(Path.Combine(root, $"chapter{chapter:00}.json"),
JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }));
}
正常时序则是:投影 → clamp → 最近点 → 距离/法线。额外固定样本 p=(4,5) 到盒 [-1,-2]..[2,3] 的距离为 sqrt(8)=2.828427m;球内点 (0,0,1) 对半径 2m 球的有符号距离为 -1m。线段、盒与球因此都以可复算距离交付上层。
边界残差使用 360 个圆周点检验零等高线;网格检验符号在形状内外连续变化。前者抓半径或单位错误,后者暴露符号翻转。两者职责不同,不能用一张看起来正确的热力图替代数值断言。
固定运行
以下命令执行全卷同一程序;本章不是另写一份演示代码。进程退出码为 0,15 章全部通过,无失败和跳过;计时只证明同一次运行,不用于性能结论。
cd "$WORK_DIR"
/usr/bin/time -p dotnet run \
--project .tmp/game-math-lab/GameMathLab.csproj \
-- --out .tmp/game-math-lab/out
CH11 PASS grid=4941 closest=(1.654,0.731) t=0.730769 boundary_residual=1.192E-007m
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0
{
"grid_samples": 4941,
"closest": { "x": 1.653846, "y": 0.7307692 },
"segment_t": 0.7307692,
"degenerate_t": 0,
"point_aabb_distance_m": 2.8284271247461903,
"sphere_inside_distance_m": -1,
"boundary_max_residual_m": 1.1920929E-07
}
交给射线
直接为每一种形状写 bool Overlap(...) 很快,但会丢掉距离、最近点和法线,移动、拾取与调试又会重复求解。当前选择是保留几何量,代价是调用者要明确处理零距离法线。若网格查询成为热点,再增加空间索引;4941 个规则样本只验证数学,不支持任何吞吐结论。
下一章只增加射线参数 t 与统一命中结果。它会复用本章的“最近位置必须回代到形状表面”不变量,把拾取、视线和移动前探测收敛到同一个 Hit(t, point, normal) 接口。