层级剪切下的包围球剔除:最大轴缩放为何不够

一个半径为 1 的局部包围球,经过 x′=x+y,y′=y,z′=z 的变换后,在某个方向上伸到了约 1.618。若剔除器只检查三个变换后基向量的长度,得到的最大值却是约 1.414。把裁剪平面放在该方向的 1.5 处,两种判断立即分歧:前者仍有几何越过平面,后者会把整个对象删除。

这个反例不需要动画、异步回读或错误的 Bounds 更新。即使局部球完整包住了模型,只要世界变换包含剪切,半径转换本身就可能失去保守性。父级非均匀缩放与子级旋转组合后,世界基向量可能不再正交;只检查各节点是否使用常规位置、旋转、缩放,不能替代检查最终矩阵。这里用一个固定剪切矩阵隔离问题,不把它伪装成某款引擎的现场缺陷。

剪切后的椭球伸出近似圆形边界,并越过裁剪平面的空间示意

上图说明形状关系,不承担数值证明。后面的坐标图由相同固定输入直接绘制。

三根轴没有测到最容易伸长的方向

为了推导,暂用列向量写世界位置 p=A(c+r u)+t,其中 c 是局部球心,r≥0|u|≤1。固定剪切的线性部分为 A=[[1,1,0],[0,1,0],[0,0,1]],三列长度分别是 1、√2、1。取最大列长相当于只测三条局部轴;斜向单位向量的两个分量可以同时贡献给世界 X,所以它不受这个最大值约束。

本例 AᵀA 的最大特征值是 (3+√5)/2,最大伸长因此为 (1+√5)/2≈1.618034。这只是该矩阵的解析结果,不意味着每个对象每帧都应该求特征值。若最终线性变换的列确实正交,最大列长才等于最大伸长;一旦允许一般仿射矩阵,这项快捷判断就需要换掉。

剔除器通常只关心对象是否完全落在某个平面外。与其重新构造一个全球方向都有效的世界球,可以直接回答当前平面的问题。

把平面方向送回局部球

设世界平面的保留侧是 n·p+d≥0,法线不要求单位长度。球心代入平面得到 s=n·(Ac+t)+d。球面偏移在平面方向上的最大贡献为:

max n·A(r u) = r max (Aᵀn)·u = r |Aᵀn|,  |u| <= 1

最后一步来自点积上界,并且当 Aᵀn 非零时可以取同向单位向量达到;为零时支撑量就是零。因此 s+r|Aᵀn|<0 才能证明整个变换后球位于该平面外。这里没有求逆,某一维缩放为零时仍然有意义。

实际实现位于 SpherePlane.csSpherePlane.Evaluate,由 Program.cs 固定样本和回归检查调用。代码使用 .NET 9 的 System.Numerics 行向量存储约定,平移在 M41..M43。推导里的 A 是数学上的列向量映射,不能把 Matrix4x4 的内存布局直接当成同一矩阵抄入公式;实现显式取每一行基向量与世界法线点积。

using System.Numerics;

public readonly record struct PlaneResult(double CenterValue, double Support, bool Outside);

public static class SpherePlane
{
    public static PlaneResult Evaluate(Vector3 center, double radius,
        Matrix4x4 world, Vector3 normal, double offset, double tolerance = 1e-6)
    {
        double[] values = [center.X, center.Y, center.Z, radius, normal.X,
            normal.Y, normal.Z, offset, tolerance, world.M11, world.M12,
            world.M13, world.M14, world.M21, world.M22, world.M23, world.M24,
            world.M31, world.M32, world.M33, world.M34, world.M41, world.M42,
            world.M43, world.M44];
        if (values.Any(v => !double.IsFinite(v)) || radius < 0 || tolerance < 0)
            throw new ArgumentException("Invalid finite input");
        if (world.M14 != 0 || world.M24 != 0 || world.M34 != 0 || world.M44 != 1)
            throw new ArgumentException("Affine transform required");
        double nx = normal.X, ny = normal.Y, nz = normal.Z;
        double length = Math.Sqrt(nx * nx + ny * ny + nz * nz);
        if (length == 0) throw new ArgumentException("Nonzero plane required");
        // 行向量布局:每一行基向量与世界平面法线做点积。
        double qx = world.M11 * nx + world.M12 * ny + world.M13 * nz;
        double qy = world.M21 * nx + world.M22 * ny + world.M23 * nz;
        double qz = world.M31 * nx + world.M32 * ny + world.M33 * nz;
        double s = center.X * qx + center.Y * qy + center.Z * qz
            + world.M41 * nx + world.M42 * ny + world.M43 * nz + offset;
        double support = radius * Math.Sqrt(qx * qx + qy * qy + qz * qz);
        return new(s, support, s + support < -tolerance * length);
    }
}

输入矩阵与向量保留单精度格式,关键乘积和累加转为双精度。tolerance 的单位是世界长度,所以判定右侧乘以 |n|;把同一平面的法线与偏移同时乘正数,不应改变剔除结果。负数缩放平面系数会反转保留侧,不属于这种等价变换。

非有限输入、零法线、负半径和投影矩阵被显式拒绝。调用方收到拒绝时应保留候选并记录数据错误,不能把异常默认翻译为不可见。零缩放则是有效的退化几何,无须沿用旧球或制造一个逆矩阵。这份接口接收局部球、完整仿射世界矩阵和世界平面;三者应来自同一份场景快照,不能在剔除循环中混用更新前后的数据。

让 1.5 这条边界成为回归

固定法线由 (0.8506508,0.5257311,0) 归一化,球心为零,平面偏移为 -1.5。正确的支撑量使最大平面值约为 +0.118034,错误半径则得到 -0.085786。只需保留这个样本,后续把实现改回“最大轴长度”的优化就会触发回归。

var shear = Matrix4x4.Identity;
shear.M21 = 1;
var n = Vector3.Normalize(new Vector3(0.8506508f, 0.5257311f, 0));
var result = SpherePlane.Evaluate(Vector3.Zero, 1, shear, n, -1.5);
Check("shear_false_cull_regression",
    !result.Outside && -1.5 + Math.Sqrt(2) < 0);

这里的 CheckProgram.cs 中记录通过或失败并决定进程退出码的断言函数。另一正常样本把偏移改为 -2,得到真正的完全外侧;相切、微小容差、平面正比例缩放、平移、非零局部球心、镜像、零缩放与非法输入也分别检查。它们约束的是同一条支撑计算调用链。

固定剪切、裁剪平面与错误半径的数值对照

2026-09-09 在 .NET 9 Release CPU 控制台程序中执行以下命令。$LAB_ROOT 指向包含上述源码和项目文件的研究目录;这些数据不代表 Unity 实机、GPU 执行或帧率测试。

dotnet build "$LAB_ROOT/PlaneLab.csproj" -c Release --no-restore
dotnet run --project "$LAB_ROOT/PlaneLab.csproj" -c Release --no-build
build: exit=0 warnings=0 errors=0 elapsed=1.46s
run: exit=0 passed=16 failed=0 skipped=0 elapsed=33.9709ms
center_value=-1.5 support=1.618034 wrong_radius=1.414214
correct_outside=false wrong_outside=true
sampled_projections=259200 max_excess=2.220446049250313E-16

运行计时来自程序内部计时器,包含断言输出与采样循环,不含进程启动和编译。采样把 XY 平面法线每隔一度取一次,对变换前单位圆每隔半度取一点,共检查 360×720 组投影。最大越界仅约为双精度舍入量级,小于测试阈值 1e-12。这组离散回归帮助发现矩阵转置和索引错误;连续三维范围的依据仍是前面的点积上界,不能用采样数量替代证明。

保留候选不等于证明可见

对视锥各个内向平面重复检查,任一平面证明完全外侧即可剔除;全部通过只意味着没有找到这类分离平面。靠近视锥棱角时可能多保留对象,包围球也可能本来就较松。这个方法的目标是避免当前包围体被错误缩小,不承担精确形体相交、遮挡判断或动画变形 Bounds 的职责。

固定绝对容差也不是任意坐标尺度的浮点误差证明。极端半径仍可能使乘积溢出,巨大的世界坐标会放大抵消误差;生产接入应限定数值范围,采用相机相对坐标或明确的误差界,并对非法派生值保守保留。本次测试只覆盖所列固定尺度,未测大世界极值。

若空间索引确实要求一个世界球,可以使用经过证明的最大奇异值上界,例如线性矩阵的 Frobenius 范数,但包围范围会更松。若下游只需要少量平面判断,直接提交局部球与仿射变换,按方向计算支撑量,能够保留更贴近当前边界的判定。两种选择应由消费接口决定,不能为了缓存一个半径而悄悄禁止场景层级中的剪切。

开头那段越过 1.5 的几何现在能够继续进入后续渲染判断。修复所依赖的合同很具体:剔除端必须拿到真实的完整线性变换,并对正在测试的方向证明包围范围;三根轴上的伸长统计不足以承担这个证明。