· 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)]

固定 OBB 姿态、A1×B2 投影区间与八角点复核结果

投影半径与八角点给出同一结果

研究目录 .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 则是世界单位的接触容差,两者不是同一量纲。跳过近零叉积是一种保守近似,可能在浮点边界把极小分离保留为接触。大世界坐标、极端尺寸比或需要稳定接触流形时,应把尺度策略、双精度和持续碰撞检测另行纳入合同。

六轴检查仍可作为便宜的提前拒绝:任一面法线分离就立即返回。只有六轴全部重叠时,才继续计算九个边叉积。它可以缩短常见分离路径,却不能成为窄相位的最终“相交”结论;在斜放盒体之间,决定那堵隐形墙是否存在的,往往正是被省略的第七到第十五个方向。