· 5 分钟阅读 · 2022 字
斜放的盒体在六个面法线上都重叠,为什么仍未相交
两个斜放的盒体没有接触,窄相位却持续返回碰撞。若把它接到角色控制器,结果不是穿透,而是角色被一堵看不见的墙挡住。六个面法线的投影区间确实全部重叠;错误在于把这六次检查当成了三维有向包围盒的完整分离轴测试。

六次重叠只排除了六种分离方向
分离轴定理判断的是:是否存在一个方向,使两个凸体投影区间不相交。OBB 的候选方向来自两类几何特征。两个盒体各有三条互相正交的局部轴,它们同时也是六个面的法线;此外,每条 A 盒边与每条 B 盒边的叉积还会产生九个候选方向。只检查前六个方向,可以证明盒体没有沿任何面法线分开,不能排除两条斜边之间存在空隙。
对候选轴 L,无需先归一化就能计算盒体的投影半径:
r = ex * abs(dot(AxisX, L))
+ ey * abs(dot(AxisY, L))
+ ez * abs(dot(AxisZ, L))
gap = abs(dot(CenterB - CenterA, L)) - radiusA - radiusB
gap > 0 表示区间分离。为了让返回间隙使用世界单位,实现最后除以 |L|。这不是两个盒体的欧氏最近距离,而是当前十五个候选轴中最大的投影间隙;它适合解释判定和生成接触法线候选,不能直接替代通用最近点查询。
漏检只在三维里显得反直觉。两个矩形在平面内相交时,候选轴就是两者的边法线;到了三维,最近特征可能不是面与顶点,而是两条不平行的边。两条边方向的叉积同时垂直于它们,正是观察这对边之间空隙的方向。固定案例的两个盒体在六个面法线上互相遮住,但沿这条共同垂线看过去,投影之间仍有一段空白。面法线没有计算错,只是没有覆盖决定结果的特征组合。
固定案例的 A 盒中心在原点,半尺寸为 (0.833764, 1.887452, 1.823530);B 盒中心为 (-2.171778, -2.012874, 2.111567),半尺寸为 (1.061493, 1.044604, 1.472044)。六个面法线中的最大候选间隙仍为 -0.0076251,所以简化实现报告重叠。补齐边叉积后,A1×B2 给出 0.14671521 的正间隙。
两盒的局部单位轴也属于固定输入,按 AxisX、AxisY、AxisZ 排列如下;中心、半尺寸与这两组三轴已经足够独立重算全部十五次投影:
A = [(-0.443449, 0.528599, -0.723834),
( 0.293785, 0.848689, 0.439793),
( 0.846784, -0.017626, -0.531645)]
B = [(-0.319965, -0.256745, 0.911978),
( 0.917602, 0.155652, 0.365758),
(-0.235857, 0.953863, 0.185786)]

投影半径与八角点给出同一结果
研究目录 .tmp/daily-labs/2026-11-01-2000/ 中,ObbSat.cs 的 Test 构造 15 个候选轴,跳过数值上近乎平行的边叉积,再以投影半径求最大间隙。核心实现如下:
using System.Numerics;
public readonly record struct OrientedBox(
Vector3 Center,
Vector3 HalfExtent,
Vector3 AxisX,
Vector3 AxisY,
Vector3 AxisZ)
{
public Vector3 Axis(int index) => index switch
{
0 => AxisX,
1 => AxisY,
2 => AxisZ,
_ => throw new ArgumentOutOfRangeException(nameof(index))
};
}
public readonly record struct SatResult(
bool Overlap,
string Axis,
float Gap);
public static class ObbSat
{
public const float SeparationTolerance = 1e-6f;
public static SatResult Test(
OrientedBox a,
OrientedBox b,
bool includeEdgeCrossAxes = true)
{
Validate(a);
Validate(b);
Vector3 delta = b.Center - a.Center;
var candidates = new List<(Vector3 Value, string Name)>(15);
for (int i = 0; i < 3; i++)
{
candidates.Add((a.Axis(i), "A" + i));
candidates.Add((b.Axis(i), "B" + i));
}
if (includeEdgeCrossAxes)
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
candidates.Add((
Vector3.Cross(a.Axis(i), b.Axis(j)),
$"A{i}xB{j}"));
float largestGap = float.NegativeInfinity;
string axisName = "none";
foreach (var candidate in candidates)
{
float lengthSquared = candidate.Value.LengthSquared();
if (lengthSquared < 1e-12f)
continue;
float distance = MathF.Abs(
Vector3.Dot(delta, candidate.Value));
float radius = Radius(a, candidate.Value) +
Radius(b, candidate.Value);
float gap = (distance - radius) /
MathF.Sqrt(lengthSquared);
if (gap > largestGap)
(largestGap, axisName) = (gap, candidate.Name);
}
return new SatResult(
largestGap <= SeparationTolerance,
axisName,
largestGap);
}
private static float Radius(OrientedBox box, Vector3 axis) =>
box.HalfExtent.X * MathF.Abs(Vector3.Dot(box.AxisX, axis)) +
box.HalfExtent.Y * MathF.Abs(Vector3.Dot(box.AxisY, axis)) +
box.HalfExtent.Z * MathF.Abs(Vector3.Dot(box.AxisZ, axis));
private static void Validate(OrientedBox box)
{
if (!Finite(box.Center) || !Finite(box.HalfExtent) ||
box.HalfExtent.X < 0 || box.HalfExtent.Y < 0 ||
box.HalfExtent.Z < 0)
throw new ArgumentException("Invalid box values");
if (!Finite(box.AxisX) || !Finite(box.AxisY) ||
!Finite(box.AxisZ) ||
MathF.Abs(box.AxisX.LengthSquared() - 1) > 1e-4f ||
MathF.Abs(box.AxisY.LengthSquared() - 1) > 1e-4f ||
MathF.Abs(box.AxisZ.LengthSquared() - 1) > 1e-4f ||
MathF.Abs(Vector3.Dot(box.AxisX, box.AxisY)) > 1e-4f ||
MathF.Abs(Vector3.Dot(box.AxisX, box.AxisZ)) > 1e-4f ||
MathF.Abs(Vector3.Dot(box.AxisY, box.AxisZ)) > 1e-4f)
throw new ArgumentException("Box axes must be orthonormal");
}
private static bool Finite(Vector3 value) =>
float.IsFinite(value.X) &&
float.IsFinite(value.Y) &&
float.IsFinite(value.Z);
}
实现保留未归一化候选轴完成投影,比较结束后再将间隙换算为世界单位。边几乎平行时,叉积长度趋近于零,归一化会放大舍入误差;程序因此跳过长度平方小于 1e-12 的方向。严格平行时这条叉积轴退化为零向量,不构成有效投影方向;近平行时跳过仍是有限精度下的保守近似,可能漏掉极小分离。
失败回归没有用同一条半径公式自证。ProjectCorners 显式生成每个盒体的八个角点,再分别取 A1×B2 上的最小和最大投影;两个区间相减得到 0.1467154,与快速公式相差约 1.9e-7。这条角点路径更慢,但计算形状不同,适合作为固定输入的独立预言机。
Program.cs 还覆盖完全重叠、面分离、刚好接触、旋转重叠、零半尺寸、非法负半尺寸、非有限输入、非单位轴、容差内外和近平行姿态。随机部分使用种子 11423 生成 10,000 对刚性正交 OBB:两个中心的各分量均匀取自 [-2,2],半尺寸各分量取自 [0.2,2],偏航角与滚转角取自 [-π,π],俯仰角取自 [-π/2,π/2]。交换 A/B 后的布尔结果零分歧。相同分布下,仅六轴版本出现 272 次假阳性。这个计数只说明遗漏叉积轴不是罕见的构造题,不代表任何游戏场景的碰撞率或性能收益。
2026-11-01 使用 .NET 9 Release 执行:
cd "$WORK_DIR"
dotnet build .tmp/daily-labs/2026-11-01-2000/ObbSatLab.csproj -c Release --no-restore
dotnet run --project .tmp/daily-labs/2026-11-01-2000/ObbSatLab.csproj -c Release --no-build
build: exit=0, warnings=0, errors=0
run: exit=0, passed=22, failed=0, skipped=0
elapsed_ms=357.5648
face_axes_overlap=true, largest_gap=-0.0076251035
full_sat_overlap=false, separating_axis=A1xB2
full_sat_gap=0.14671521, corner_oracle_gap=0.1467154
random_count=10000, symmetry_differences=0
face_only_false_positives=272
耗时包含测试框架、角点复核和随机数据生成,不能作为引擎窄相位基准。当前合同也只接受轴正交且单位化的刚性 OBB,半尺寸必须非负。若把带剪切或非均匀缩放的矩阵列直接当作盒轴,投影半径公式的几何前提已经失效,应该先重建正交盒或改用一般凸体表示。
1e-12 作用于单位轴叉积的长度平方,用于跳过近平行边产生的退化方向;1e-6 则是世界单位的接触容差,两者不是同一量纲。跳过近零叉积是一种保守近似,可能在浮点边界把极小分离保留为接触。大世界坐标、极端尺寸比或需要稳定接触流形时,应把尺度策略、双精度和持续碰撞检测另行纳入合同。
六轴检查仍可作为便宜的提前拒绝:任一面法线分离就立即返回。只有六轴全部重叠时,才继续计算九个边叉积。它可以缩短常见分离路径,却不能成为窄相位的最终“相交”结论;在斜放盒体之间,决定那堵隐形墙是否存在的,往往正是被省略的第七到第十五个方向。