· 5 分钟阅读 · 1871 字

裁剪平面方向正确却切错位置:归一化必须同时处理常数项

一个跟随模型移动的裁剪平面,方向始终朝上,法线长度也始终为 1,却在模型拉高后切到了错误位置。检查法线可视化找不到异常,因为错误藏在平面方程的第四个系数里:把前三项归一化、保留原来的常数项,相当于重新指定了平面与原点的距离。

纸面高度与木制间隔的概念示意,不代表实验现场

固定案例只需要一个局部平面 y=1。模型沿 Y 轴放大 3 倍,再向上平移 5,原平面上的点显然应该落在世界 y=8。研究程序的错误分支却返回 y=2.6666667。两个结果拥有完全相同的单位法线 (0,1,0),差异足以让裁剪边界穿过原本应完整保留的模型区域。

为什么逆转置以后还会切错

用 p=(nx,ny,nz,d) 表示平面,点满足 n·x+d=0。常数项 d 的符号约定固定为加号;只有 |n|=1 时,表达式的值才是带符号的距离。把平面四项同乘非零常数不改变零点集合,但乘负数会翻转正负半空间。

本次实现使用 C# System.Numerics 的行向量约定:点按 xWorld=xLocal*M 变换。因此系数向量按 pWorld=pLocal*transpose(inverse(M)) 变换。将它代回点与平面的内积,M 与其逆矩阵相消,原平面上的点仍得到零。矩阵库若采用列向量约定,代码的乘法方向需要随之调整,不能只按公式中的单词顺序搬运。

局部系数 (0,1,0,-1) 经上述缩放和平移,得到 (0,1/3,0,-8/3)。这已经是正确的世界平面:y/3-8/3=0,解仍为 y=8。为了让后续距离阈值采用世界单位,需要将整个四维向量除以法线长度 1/3,得到 (0,1,0,-8)。

错误代码只把前三项改为 (0,1,0),留下 d=-8/3,于是方程变成 y-8/3=0。它不是浮点误差,也不是矩阵乘法顺序产生的近似偏差;即使使用无限精度,错误平面也会停在同一个位置。增大裁剪容差只能掩盖一部分表面缺失,无法恢复原来的几何边界。

固定输入下原始系数、整体归一化与错误归一化的裁剪高度

把距离合同放进返回值

研究目录 .tmp/daily-labs/2026-11-01-1930/ 中的 PlaneTransform.cs 由 ToWorld 完成逆转置与整体归一化,Evaluate 消费返回的世界平面。这里返回四个系数而非分别返回法线和偏移量,调用方应将它们作为一个值一起缓存、上传和更新。以下是完整核心实现:

using System.Numerics;

public static class PlaneTransform
{
    public static Vector4 ToWorld(Vector4 local, Matrix4x4 world)
    {
        if (!Finite(local) || new Vector3(local.X, local.Y, local.Z).Length() < 1e-6f)
            throw new ArgumentException("Invalid plane");
        if (world.M14 != 0 || world.M24 != 0 || world.M34 != 0 || world.M44 != 1)
            throw new ArgumentException("Expected affine transform");
        if (!Matrix4x4.Invert(world, out var inverse))
            throw new ArgumentException("Singular transform");
        // 行向量约定下,平面系数乘逆矩阵的转置。
        var raw = Vector4.Transform(local, Matrix4x4.Transpose(inverse));
        float length = new Vector3(raw.X, raw.Y, raw.Z).Length();
        if (!Finite(raw) || !float.IsFinite(length) || length < 1e-6f)
            throw new ArgumentException("Invalid transformed plane");
        return raw / length;
    }
    public static float Evaluate(Vector4 p, Vector3 x) =>
        Vector3.Dot(new Vector3(p.X, p.Y, p.Z), x) + p.W;
    private static bool Finite(Vector4 p) => float.IsFinite(p.X) &&
        float.IsFinite(p.Y) && float.IsFinite(p.Z) && float.IsFinite(p.W);
}

实现限定为可逆仿射变换,拒绝投影矩阵和退化法线。1e-6 是当前输入尺度下的退化阈值,不是覆盖任意尺度的通用误差常数。特别大的缩放会把逆变换后的法线压得很短,进入拒绝分支;接入大世界或极端缩放资产时,需要另行设计条件数和精度策略,不能静默放宽阈值。

模型变换与局部平面共同决定世界系数。两者任一改变,都需要重新计算;只在旋转变化时刷新法线,会漏掉平移对 d 的影响。用于 Shader 裁剪时,点也必须处于世界空间;如果顶点仍在局部空间,最直接的选择是继续使用局部平面,避免把两个空间的量放进一次点积。

只判断严格正负半空间时,可以保留逆转置后的未归一化系数,前提是始终保留整组系数与方向。如果判断条件包含以米计的容差、边缘宽度或软裁剪距离,就必须使用单位法线,或者显式把阈值乘以法线长度。两种合同不能共用一个未注明单位的 distance 字段。

回归不能只检查法线长度

Program.cs 中的 Check 和 Reject 覆盖固定表面点、正负半空间、恒等变换、平面等比例系数、负比例导致的朝向翻转、镜像缩放,以及零法线、NaN、无穷常数项、奇异矩阵和投影矩阵的拒绝行为。决定性失败样本保留在同一条调用链中:

var local = new Vector4(0, 1, 0, -1);
var matrix = Matrix4x4.CreateScale(2, 3, 1) *
    Matrix4x4.CreateTranslation(0, 5, 0);
var correct = PlaneTransform.ToWorld(local, matrix);
Matrix4x4.Invert(matrix, out var inverse);
var raw = Vector4.Transform(local, Matrix4x4.Transpose(inverse));
var wrong = new Vector4(
    Vector3.Normalize(new Vector3(raw.X, raw.Y, raw.Z)), raw.W);
Check(Math.Abs(correct.W + 8) < 1e-5);
Check(Math.Abs(PlaneTransform.Evaluate(correct, new Vector3(2, 8, 3))) < 1e-5);
Check(Math.Abs(PlaneTransform.Evaluate(wrong, new Vector3(2, 8, 3)) - 16f / 3) < 1e-5);

第二组验证固定种子为 71031:生成 10,000 组仿射变换,三个缩放分量分别位于 [0.2,5),旋转由随机偏航、俯仰、滚转角构成,平移分量位于 [-20,20)。每组从局部平面取一个 x,z∈[-10,10) 的点,与平面分别变换到世界空间,检查点代入方程后的残差,同时检查法线单位长度。它验证的是平面与点的对应关系,能够发现“方向对、位置错”这类长度测试看不到的错误。

2026-11-01 使用 .NET 9 Release 执行:

cd "$WORK_DIR"
dotnet build .tmp/daily-labs/2026-11-01-1930/PlaneTransformLab.csproj -c Release --no-restore
dotnet run --project .tmp/daily-labs/2026-11-01-1930/PlaneTransformLab.csproj -c Release --no-build
build: exit=0, warnings=0, errors=0
run: exit=0, passed=18, failed=0, skipped=0
elapsed_ms=47.8555
correct_world_y=8
wrong_world_y=2.6666667
wrong_residual_at_surface=5.333333
random_count=10000
max_surface_residual=8.583069e-6
max_normal_length_error=1.1920929e-7

这里的耗时只记录本轮正确性检查,不代表引擎渲染开销。随机样本也没有覆盖病态剪切、巨大平移或接近奇异的矩阵。固定输入已经足以证明错误归一化改变了零点集合,随机回归则为当前数值范围补上了旋转、缩放和平移组合下的证据。

裁剪系统的接口因此需要明确两件事:平面在哪个空间,以及系数是否已按法线长度整体归一化。前者决定能与哪些点相乘,后者决定结果能否直接与世界距离比较。把这两个条件固化到世界平面的构造入口,可以让裁剪、距离着色和碰撞侧判断共享同一组几何系数;单独修正法线方向,无法提供这份合同。