· 6 分钟阅读 · 2372 字
圆形角色擦过墙角:扩张 AABB 为什么会提前挡住移动
半径为 1 的圆形角色沿墙角下方水平移动,终点到障碍的最近距离仍有 1.204159,移动却被截断了。若碰撞代码先把障碍矩形向四边各扩张一个半径,再对角色中心做线段与盒子求交,这个结果完全可能出现:代码算对了盒子的交点,盒子却大于角色真正不能进入的区域。
这种保守近似适合筛选候选。若把它的进入时间直接交给移动控制器,误差就会表现为墙角旁的一块隐形障碍。接触后再沿切线滑动也无法补救,因为控制器从一开始就收到了错误的接触位置。

先问角色中心究竟不能进入哪里
固定障碍为 [0,2] × [0,2],圆半径为 1。坐标 X 向右、Y 向上;中心从 (-2,-0.9) 出发,本次位移是 (1.2,0),时间参数 t 表示这一整段位移的比例,范围为 [0,1],并非秒数。障碍静止,角色不改变半径,轨迹是直线。
中心终点是 (-0.8,-0.9),最近障碍点为左下角 (0,0),距离平方为 0.64+0.81=1.45,大于半径平方 1。沿这段轨迹,中心持续接近该角,终点已经是最接近的位置,因而整段都没有接触。
扩张 AABB 却是 [-1,3] × [-1,3],线段在 t=5/6 时进入它。错误来自四个角:圆心与原矩形的距离不超过半径的区域,是矩形与圆盘的 Minkowski 和。它的边界由四条平移后的有限线段与四段四分之一圆弧组成。扩张盒子把每段圆弧外面的角落也填满了。
圆盘扩张集合包含在扩张 AABB 内,因此宽阶段可以保留“盒子未命中就无需精查”的筛选方向;反过来,盒子命中仅说明有必要继续检查。这个区分应直接体现在接口含义中,避免候选时间被调用层当成最终接触时间。
直边和圆角各自提交接触候选
研究探针 SweepLab 的 CircleSweep.cs 保留两个接口:ExpandedBox 计算候选,Hit 返回实际首次接触比例或 null。Hit 不依赖宽阶段,便于单独核验。下面是完整实现,运行环境为 .NET 9。
namespace SweepLab;
public readonly record struct V(double X, double Y)
{
public static V operator +(V a, V b) => new(a.X + b.X, a.Y + b.Y);
public static V operator -(V a, V b) => new(a.X - b.X, a.Y - b.Y);
public static V operator *(V a, double k) => new(a.X * k, a.Y * k);
public double Length2 => X * X + Y * Y;
}
public readonly record struct Box(double MinX, double MinY, double MaxX, double MaxY);
public static class CircleSweep
{
public static double Distance2(V p, Box b)
{
double dx = Math.Max(Math.Max(b.MinX - p.X, 0), p.X - b.MaxX);
double dy = Math.Max(Math.Max(b.MinY - p.Y, 0), p.Y - b.MaxY);
return dx * dx + dy * dy;
}
public static double? Hit(V p, V d, double r, Box b)
{
if (Distance2(p, b) <= r * r) return 0;
if (d.Length2 == 0) return null;
double best = double.PositiveInfinity;
void Accept(double t) { if (t >= 0 && t <= 1) best = Math.Min(best, t); }
if (d.X != 0)
{
foreach (double x in new[] { b.MinX - r, b.MaxX + r })
{
double t = (x - p.X) / d.X, y = p.Y + d.Y * t;
if (y >= b.MinY && y <= b.MaxY) Accept(t);
}
}
if (d.Y != 0)
{
foreach (double y in new[] { b.MinY - r, b.MaxY + r })
{
double t = (y - p.Y) / d.Y, x = p.X + d.X * t;
if (x >= b.MinX && x <= b.MaxX) Accept(t);
}
}
foreach (double x in new[] { b.MinX, b.MaxX })
foreach (double y in new[] { b.MinY, b.MaxY })
{
var c = new V(x, y);
foreach (double t in CircleRoots(p - c, d, r))
{
var q = p + d * t;
bool outerX = x == b.MinX ? q.X <= x : q.X >= x;
bool outerY = y == b.MinY ? q.Y <= y : q.Y >= y;
if (outerX && outerY) Accept(t);
}
}
return double.IsFinite(best) ? best : null;
}
private static IEnumerable<double> CircleRoots(V m, V d, double r)
{
double a = d.Length2, h = m.X * d.X + m.Y * d.Y;
double c = m.Length2 - r * r, disc = h * h - a * c;
if (disc < 0) yield break;
double root = Math.Sqrt(disc);
double q = -h - Math.CopySign(root, h);
if (q == 0) { yield return -h / a; yield break; }
yield return q / a;
yield return c / q;
}
public static double? ExpandedBox(V p, V d, double r, Box b)
{
double enter = 0, exit = 1;
bool Axis(double v, double dv, double lo, double hi)
{
if (dv == 0) return v >= lo && v <= hi;
double t0 = (lo - v) / dv, t1 = (hi - v) / dv;
enter = Math.Max(enter, Math.Min(t0, t1));
exit = Math.Min(exit, Math.Max(t0, t1));
return enter <= exit;
}
return Axis(p.X, d.X, b.MinX-r, b.MaxX+r) &&
Axis(p.Y, d.Y, b.MinY-r, b.MaxY+r) ? enter : null;
}
}
Hit 先以点到盒子的最近距离判断初始接触或重叠,统一返回 0;静止且分离则返回 null。随后检查所有边界特征,只接受 [0,1] 内的候选,最后取最小值。这里查询的是包含相切的闭集接触,不负责穿透修正,也不区分“初始贴边后离开”与“正在进入”。
偏移直边的求交只需一次除法,但交点在另一个轴上必须落入原矩形的边长范围。角点则求解 |p+t*d-c|²=r²。代码里的 a=d·d、h=(p-c)·d、c=|p-c|²-r² 对应方程 a*t²+2*h*t+c=0。求根使用一个直接根和根的乘积关系,减轻相近数相减带来的损失;两个根都交给候选筛选,不依赖返回顺序。
每个角对应的整圆大部分都不属于圆角边界,所以还要检查交点是否落在该角的外侧象限。漏掉这个条件,就把用来构造边界的完整圆周混进了接触判定。对于从外部开始的连续线段,进入闭合扩张区域前必然先经过其边界;直边与圆角覆盖了全部边界,因此最早有效候选就是所求接触比例。这是当前几何分解成立的依据。
这里的“精确”指不再用方角近似圆角,不表示浮点运算无误差。探针只验证适中量级的有限数值、正半径和非退化矩形;没有输入清洗、尺度归一化或判别式误差界。极大坐标可能溢出,几乎相切时的舍入也可能改变命中分类。正式接入应先确立世界尺度与接触容差,不能把一个任意 epsilon 加进所有判断后继续宣称没有漏检。

把同一条轨迹延长到位移 (3,0),这次确实会碰到左下圆角。接触圆心的 X 为 -sqrt(1-0.9²),因此 t=(2-sqrt(0.19))/3。宽阶段在 1/3 就报告进入,实际接触却约为 0.521370。误报与提前接触是同一处几何近似造成的两种结果。
回归既要挡住误报,也要保留擦边
Program.cs 的 Check 保存短轨迹无碰撞、长轨迹解析接触、四个方向直边接触、相切、越过整块障碍、终点恰好接触、初始重叠、静止与背向移动等断言。关键失败样本和正常样本如下,Near 的时间容差为 1e-10。
var box = new Box(0, 0, 2, 2);
var start = new V(-2, -.9);
Check(CircleSweep.Hit(start, new(1.2, 0), 1, box) is null,
"short false positive rejected");
Check(Near(CircleSweep.Hit(start, new(3, 0), 1, box),
(2-Math.Sqrt(.19))/3), "corner analytic");
Check(Near(CircleSweep.Hit(new(-2,-1), new(4,0), 1, box), .5),
"tangent");
2026-10-16 以 Release 配置执行,构建和运行退出码均为 0。以下命令在研究目录运行;耗时是包含测试循环的批次计时,不是性能基准。
dotnet build SweepLab.csproj -c Release --no-restore --nologo
dotnet run --project SweepLab.csproj -c Release --no-build
Build succeeded. 0 warnings, 0 errors
passed=21 failed=0 skipped=0
paths=8281 samples=836381 hits=2529
falsePositives=372
elapsedMs=63.2687
short: broad=0.8333333333333334 exact=null
long: broad=0.3333333333333333 exact=0.5213700352153111
contact=(-0.4358898943540668,-0.9)
网格输入由起点两个分量各取 [-6,6]*0.75、位移两个分量各取 [-3,3]*1.25 构成,共 8,281 条轨迹。每条取 101 个等间隔点,以点到矩形最近距离检查:采样点一旦占据扩张区域,扫掠结果必须已在此之前命中。另检查返回点距离、非零接触前的微小退步仍在外部、左右镜像结果,以及每个精确命中都被宽阶段覆盖。372 条轨迹仅被宽阶段判中;这个数字只描述该固定输入网格,不能推广为游戏中的误报率。
采样可能漏过持续极短的交叠,相切也可能恰好落在采样间隙,因此它不是独立完备的连续碰撞判定器。解析接触值与显式相切回归承担另一部分检验,边界特征覆盖的推导再说明为何不需要沿轨迹逐步试探。
若角色实际采用轴对齐方形碰撞体,扩张 AABB 可以直接对应其几何;若采用圆形,就应把圆角留给窄阶段。本探针只输出单障碍首次接触时间,没有实现法线、多障碍选取、滑动、穿透恢复或引擎物理步进,也没有把独立 C# 运行当成 Unity 或 Godot 场景测试。移动控制器只有在明确这些合同之后,才能决定在哪里截断剩余位移;不能用一个保守候选替它作出最终阻挡决定。