游戏中的数学 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,中间值对应线段内部。接触求解器可据此选择端点或侧面法线,不必再从坐标误差重新推断。这个参数无单位,不能与第 12 章以米计的射线 t 混用。
有符号距离
圆的有符号距离 |q|-r 在外部为正、边界为零、内部为负。实验在 [-4,4]×[-3,3] 上以 0.1m 间距取样,共得到 4941 个点;热力图用连续色标明确标出负值、零和正值,并把半径 2m 的零等高线直接画在采样场上。
// 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 }));
}
求解顺序固定为投影、截断、最近点、距离与法线。额外样本 p=(4,5) 到盒 [-1,-2]..[2,3] 的距离为 sqrt(8)=2.828427m;球内点 (0,0,1) 对半径 2m 球的有符号距离为 -1m。线段、盒与球由此交付同一种可复算的距离语义。
边界残差使用 360 个圆周点检验零等高线;网格检验符号在形状内外连续变化。前者抓半径或单位错误,后者暴露符号翻转。两者职责不同,不能用一张看起来正确的热力图替代数值断言。
几何校验
同一程序同时生成 JSON、两张数学图和边界断言。退出码为 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=148.7
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 个规则样本验证的是数学,不是吞吐。
第 12 章在这套距离合同上增加射线参数 t,并继续用“命中点必须回代到形状表面”校验结果。拾取、视线和移动前探测将收敛到同一个 Hit(t, point, normal) 接口。