视锥剔除 AABB:中心在平面外,不代表整个盒子不可见
把一段墙的包围盒放在视锥边缘:中心位于 x=4.5,横向半尺寸为 1,保留区域从 x=5 开始。墙的右端已经伸到 5.5,用中心点做剔除却会删掉整段墙。调大相机视角只会移动错误发生的位置;真正缺少的是盒子沿裁剪方向占据的范围。

这里把问题限定在 CPU 可见性筛选:输入已经是同一坐标空间中的 AABB 与内向平面,不涉及从投影矩阵提取平面,也不重新计算动态 Mesh 的 Bounds。图示解释跨面的关系,下面的固定数值与代码负责检验排除条件。
负的是中心值,未必是最大值
约定平面写作 n·x+d=0,保留侧为 n·x+d>=0。取 n=(2,0,0)、d=-10,它依然表示 x=5,法线长度不必是 1。盒子中心记作 c,非负半尺寸记作 e;任何盒内点都可以写成 c+u,其中各分量满足 |u_i|<=e_i。
中心代入得到 s=n·c+d=-1。但点积的三个分量可以独立选取正负端点,因此盒内最大值是 s+r,其中 r=|n_x|e_x+|n_y|e_y+|n_z|e_z。当前 e=(1,0.5,0.5),于是 r=2,最大值为 1。只有连最大值也小于零,才能证明整个盒子都在排除侧。这个推导同时解释了绝对值为何不可省略:法线为负的轴,要取盒子的另一端才能得到最大值。
这里的 r 是投影半径,不是包围球半径。直接使用 e.X 或 e 的长度,都会把平面的方向和系数尺度丢掉。若预先归一化平面,必须让 n 与 d 同除法线长度;只归一化法线会把本例边界从 x=5 改成 x=10。

容差属于坐标空间,不属于平面系数
恰好贴住边界时,s+r=0。剔除器采用严格小于,把这种情况保留给后续阶段。若还允许一段世界距离 epsilon 的余量,判据应为 s+r < -epsilon*|n|。当所有输入使用米时,epsilon 才对应米;换成局部空间以后,它的单位也随之改变。
例如把中心移到 3.999,盒子最右端为 4.999,离保留侧还差 0.001。设置 epsilon=0.002 时保留,设为 0.0005 时排除。将平面整体乘以 10,几何区域没有变,容差右侧也必须跟着乘以 10。直接减一个固定的系数值,会让矩阵或平面表示方式决定物体是否消失。
研究工程的 PlaneAabb.cs 保留完整实现如下。Program.Main → PlaneAabb.Project → Projection.Outside 是固定案例和测试共同使用的调用链,输入验证放在计算之前。
namespace PlaneAabbLab;
public readonly record struct V3(double X, double Y, double Z);
public readonly record struct Plane(V3 N, double D);
public readonly record struct Box(V3 Center, V3 Extents);
public readonly record struct Projection(double Center, double Radius, double Tolerance)
{
public bool Outside => Center + Radius < -Tolerance;
}
public static class PlaneAabb
{
// 有界输入避免中间乘积溢出;零法线没有半空间方向。
static void Check(double x)
{
if (!double.IsFinite(x) || Math.Abs(x) > 1e100)
throw new ArgumentOutOfRangeException(nameof(x));
}
public static Projection Project(Plane p, Box b, double epsilon = 0)
{
foreach (double value in new[] { p.N.X, p.N.Y, p.N.Z, p.D,
b.Center.X, b.Center.Y, b.Center.Z,
b.Extents.X, b.Extents.Y, b.Extents.Z, epsilon }) Check(value);
if (epsilon < 0 || b.Extents.X < 0 || b.Extents.Y < 0 || b.Extents.Z < 0)
throw new ArgumentOutOfRangeException(nameof(b));
double scale = Math.Max(Math.Abs(p.N.X), Math.Max(Math.Abs(p.N.Y), Math.Abs(p.N.Z)));
if (scale < 1e-100) throw new ArgumentOutOfRangeException(nameof(p));
// 先按最大分量缩放,再求长度,避免极小法线平方下溢。
double x = p.N.X / scale, y = p.N.Y / scale, z = p.N.Z / scale;
double length = scale * Math.Sqrt(x * x + y * y + z * z);
double s = p.N.X * b.Center.X + p.N.Y * b.Center.Y + p.N.Z * b.Center.Z + p.D;
double r = Math.Abs(p.N.X) * b.Extents.X + Math.Abs(p.N.Y) * b.Extents.Y
+ Math.Abs(p.N.Z) * b.Extents.Z;
return new(s, r, epsilon * length);
}
}
这些输入范围是示例接口的显式合同:所有标量绝对值不超过 1e100,法线最大分量至少为 1e-100。它们让中间乘积保持有限,并排除没有方向的零法线;这不代表在极端数量级下仍有足够精度。epsilon 是几何余量,也不是严格浮点误差界。大坐标中的相消、GPU 的 float 精度和具体渲染后端仍需单独验证;不能把“没有溢出”误写成“绝不误剔除”。
八角点枚举提供独立对照
Program.CornerMax 直接构造八个角点,逐个计算平面值并取最大值,不使用支持半径公式。Program.Oracle 遍历 342 个非零整数法线、17 个中心位置和 5 个横向半尺寸,共 29,070 组输入;每组再将平面乘以 0.125、2、16,检查排除结果不变。样本使用可精确表示的二进制分数,因此这部分可以直接比较最大投影相等,不用宽松误差掩盖公式错误。
中心测试的失败回归由同一运行程序执行,修复目标是保留跨面盒子,而不是让所有输入都变成可见:
Plane p = new(new(2,0,0),-10);
Box cross = new(new(4.5,0,0),new(1,.5,.5));
Projection q = PlaneAabb.Project(p,cross);
Require(q.Center < 0 && !q.Outside);
Require(PlaneAabb.Project(p, cross with {Center=new(3,0,0)}).Outside);
Require(!PlaneAabb.Project(p, cross with {Center=new(4,0,0)}).Outside);
其余断言覆盖零体积盒、容差缩放、非法半尺寸、负容差、NaN、无穷值与数值范围拒绝。2026-09-23 在 .NET 9.0.3 的 Release 配置运行;LAB_DIR 指向该研究工程目录,命令退出码均为 0。
dotnet build "$LAB_DIR/PlaneAabbLab.csproj" -c Release
dotnet run --project "$LAB_DIR/PlaneAabbLab.csproj" -c Release --no-build
build: warnings=0 errors=0
passed=16 failed=0 skipped=0
samples=29070 elapsedMs=70.1901
center=4.5 s=-1 r=2 max=1 centerOnlyOutside=true outside=false
center=3.999 epsilon=0.002 outside=false
耗时只记录本次断言程序,不能据此推断真实场景的 CPU 剔除吞吐,更不能推断 GPU 节省了多少时间。这里已经取得的是公式、反例和输入合同的可复核结果。
对整个视锥,可以逐个检查所有有效内向平面,只要任一平面证明盒子完全在外就排除。全部通过只表示“未被这些平面排除”,尤其在视锥棱角附近仍可能留下实际不相交的盒子;精确相交判断需要额外分离轴,不能从本判据直接获得。对宽阶段可见性筛选,这种保守保留通常比提前删掉墙更合适。
墙的中心仍在外侧,但现在右端进入视锥的事实能够保住整份绘制候选。接入时需要一起固定三项输入语义:Bounds 与平面共用空间、法线朝向保留侧、容差以该空间的距离计量。满足这些前提之后,渲染器才能把“确定在外”作为删除依据,并把剩余的遮挡和精确可见性问题交给后续阶段。