旋转后的 Mesh 包围盒:只变换最小点和最大点会漏掉角点

一块长条形 Mesh 绕 Z 轴转了 45°,右侧角点已经进入裁剪边界,包围盒却仍报告整个对象在边界外。最值得检查的可能只是两次点变换:取局部 minmax,分别乘上世界矩阵,再逐分量重新排序。代码没有倒置的区间,也没有异常数值,但这两个点已经不再代表世界坐标各轴的极值。

这里把问题缩到一个局部盒:X 范围为 [-2,2],Y 为 [-0.5,0.5],Z 为 [-0.25,0.25],长度统一使用世界单位。盒中可以装一块细长面板。本文只研究同一份几何经过仿射变换后的边界,不处理骨骼或顶点着色器改变局部形状的情况。

完整包围区域与遗漏角点的概念示意

主图表达完整包围与遗漏角点的区别,不承载精确坐标;下方技术图使用固定输入计算。

重新排序,找不回没有变换的角点

采用列向量约定,旋转后的横坐标是 x′=(x-y)/√2。局部最小点的 X、Y 都为负,最大点的 X、Y 都为正;变换这两个点,横坐标只能到达 ±1.5/√2,也就是约 ±1.060660

真正让横坐标最大的组合却是 x=2,y=-0.5,结果为 2.5/√2≈1.767767。这是另外一个角点。端点重新排序只能修复已经算出的两个数的顺序,无法补齐丢掉的组合。

若当前裁剪平面保留 x≥1.5 的半空间,错误盒的最大 X 小于 1.5,会直接拒绝对象;实际角点却越过了平面。这足以构成误剔除反例。正确盒与半空间相交只表示继续保留候选,并不能据此保证网格最终可见。

极值来自每个轴贡献的绝对值

将局部盒写成中心 c=(min+max)/2 与非负半尺寸 e=(max-min)/2。盒内任一点为 c+d,其中每个分量满足 |dᵢ|≤eᵢ。仿射变换写作 p′=Ap+t,中心随点变换得到 c′=Ac+t

以世界 X 轴为例,偏离中心的距离为 A₀₀dₓ+A₀₁dᵧ+A₀₂d_z。三个局部分量可以独立取正负,因此最大值恰好是 |A₀₀|eₓ+|A₀₁|eᵧ+|A₀₂|e_z。另外两轴同理,合起来就是 e′=|A|e;这里的绝对值逐元素计算,不是行列式,也不是向量长度。

这个推导直接覆盖旋转、剪切和负缩放。某一轴缩放为零也可以计算:盒会压成面、线或点,但无需逆矩阵。把“矩阵不可逆”作为拒绝条件,会无谓地排除这些合法退化边界。

下面是 C# 研究工程 AffineBounds.cs 的完整核心实现。Affine 显式保存矩阵的三行;Transform 是唯一变换入口,调用 Apply 处理中心,以三行绝对值处理半尺寸。调用方必须先将引擎矩阵映射到这份列向量合同,不能仅按存储下标照抄。

namespace BoundsLab;

public readonly record struct V(double X, double Y, double Z)
{
    public bool Finite => double.IsFinite(X) && double.IsFinite(Y) && double.IsFinite(Z);
    public static V operator +(V a, V b) => new(a.X+b.X, a.Y+b.Y, a.Z+b.Z);
    public static V operator -(V a, V b) => new(a.X-b.X, a.Y-b.Y, a.Z-b.Z);
    public static V operator *(V a, double s) => new(a.X*s, a.Y*s, a.Z*s);
    public static V Min(V a, V b) => new(Math.Min(a.X,b.X),Math.Min(a.Y,b.Y),Math.Min(a.Z,b.Z));
    public static V Max(V a, V b) => new(Math.Max(a.X,b.X),Math.Max(a.Y,b.Y),Math.Max(a.Z,b.Z));
    public double Dot(V b) => X*b.X + Y*b.Y + Z*b.Z;
    public V Abs() => new(Math.Abs(X), Math.Abs(Y), Math.Abs(Z));
}

public readonly record struct Box(V Min, V Max);

// 行向量保存矩阵,点按列向量约定进行变换。
public readonly record struct Affine(V RowX, V RowY, V RowZ, V Translation)
{
    public V Apply(V p) => new V(RowX.Dot(p), RowY.Dot(p), RowZ.Dot(p)) + Translation;
    public bool Finite => RowX.Finite && RowY.Finite && RowZ.Finite && Translation.Finite;
}

public static class AffineBounds
{
    public static Box Transform(Box local, Affine a)
    {
        V lo = local.Min, hi = local.Max;
        if (!lo.Finite || !hi.Finite || !a.Finite ||
            lo.X > hi.X || lo.Y > hi.Y || lo.Z > hi.Z)
            throw new ArgumentException("Invalid bounds or affine transform");
        // 先减半再相加,避免同号端点相加溢出。
        V c = lo * 0.5 + hi * 0.5;
        V e = hi * 0.5 - lo * 0.5;
        V wc = a.Apply(c);
        V we = new(a.RowX.Abs().Dot(e), a.RowY.Abs().Dot(e), a.RowZ.Abs().Dot(e));
        Box world = new(wc - we, wc + we);
        if (!world.Min.Finite || !world.Max.Finite)
            throw new ArgumentException("Unrepresentable transformed bounds");
        return world;
    }
}

输入拒绝非有限值和倒序区间;输出溢出也显式失败。半尺寸为零是正常数据,空集合则没有在这里编码。异常应由调用方转成保留对象或报错等明确策略,不能吞掉异常后提交一个原点零盒,否则又会制造错误剔除。

八角点与端点法在同一裁剪平面上的计算对照

用八角点核对,而不重复写一次半尺寸公式

测试入口 Program.Main 同时调用 AffineBounds.Transform 和独立的 Program.Oracle。后者枚举八种端点组合,逐个变换后归约最小值与最大值;它没有复用中心或半尺寸公式。Program.Grid 覆盖 24 个角度、5 个 X 缩放、5 个剪切系数和 3 组偏移,共 1800 组,再加上 7 组单独的有效输入核对。当前适中数值范围内,逐分量绝对误差要求小于 1e-10

错误算法保留在回归中,其决定性检查如下;变量与上面的固定盒、45°旋转一致。

V p = rotation.Apply(Local.Min), q = rotation.Apply(Local.Max);
Box wrong = new(V.Min(p,q), V.Max(p,q));
V witness = rotation.Apply(new(2,-0.5,0.25));
Check(witness.X > wrong.Max.X);
Check(witness.X <= correct.Max.X + 1e-12);
Check(wrong.Max.X < 1.5 && correct.Max.X > 1.5);

其余用例检查平移、负缩放、剪切、零缩放、点盒,以及倒序、NaN、无穷输入和输出溢出的拒绝分支。把剪切固定为 x′=x+2y 时,世界半尺寸为 (3,0.5,0.25),也与八角点枚举一致。

2026-09-15 在 .NET 9 的 Release CPU 进程中执行,$LAB_ROOT 表示包含这三个源码文件的研究工程目录:

cd "$LAB_ROOT"
dotnet build BoundsLab.csproj -c Release
dotnet run --project BoundsLab.csproj -c Release --no-build
build: exit=0 warnings=0 errors=0
run: exit=0
correctMaxX=1.767767 endpointMaxX=1.060660 witnessX=1.767767
passed=14 failed=0 skipped=0 samples=1807 elapsedMs=37.1372

耗时为本次包含断言和逐项日志的运行时间,不是性能基准;这些结果也不代表已在 Unity、Godot 或 GPU 上完成集成验证。

紧致到局部盒,仍不等于紧致到网格

实数运算下,这个结果是“整个局部盒变换后”的最小轴对齐包围盒。若原局部盒内部有大量空白,实际网格的世界盒仍可能更小。需要更紧边界时应从几何点或更细的层级计算,不能期待一个仅接收六个端点分量的函数恢复已经丢失的形状。

实现采用 double,并用先减半再相加减少端点求和的溢出风险,但这没有证明浮点结果向外包住所有精确点。极小数下溢、严重消减,以及转成 float 后的舍入仍需按生产坐标范围制定误差预算;严格保守的剔除应加入经验证的向外误差界或区间运算。测试容差用于比较两个实现,不能直接拿来当生产扩张量。

对于动态 Mesh,局部盒还必须覆盖当前顶点修订,世界矩阵也必须属于本次提交。中心与半尺寸公式解决的是完整仿射盒的极值计算,无法弥补过期几何,更不能用于带透视除法的投影边界。开篇的漏角问题因此有了明确修复位置:保留正确的局部几何边界,在进入世界剔除阶段时计算全部轴向贡献,再将浮点保守性作为单独的数值合同验证。