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

游戏中的数学 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) 接口。