· 5 分钟阅读 · 1866 字

模型拉宽后高光倾斜:归一化修不回错误的法线方向

把一块斜面沿 X 轴拉宽两倍,顶点位置正确,法线也重新归一化了,高光却仍像贴在另一块斜面上。长度检查无法发现这个问题:在本文的固定输入中,错误法线长度为 1,与变换后单位切线的点积却是 0.6。真正的表面法线应与切线垂直,点积应为零。

这个结果来自独立 C# 几何探针,未接入 Unity、Godot 或 GPU 着色器。它验证的是变换前后的方向关系,能够作为自定义实例数据、离线 Mesh 烘焙和着色器接入时的数值回归;文章中的高光现象是这一几何错误对应的渲染问题,没有把示意图作为引擎实测截图。

拉伸斜面与两种方向的工程手绘概念图,非引擎运行截图

长度正确,垂直关系仍然可能错误

局部空间取单位法线 n=(1,1,0)/√2,单位切线 t=(1,-1,0)/√2。两者点积为零。顶点采用列向量约定,线性变换为 S=diag(2,1,1);平移只影响位置,不进入切线和法线的计算。

切线来自两个邻近位置的差,因此随表面乘以 S,得到 (2,-1,0)/√2。如果把法线也乘以 S,就得到 (2,1,0)/√2。这两个方向已经不垂直。分别归一化后,它们仍有 0.6 的点积;归一化只改向量的长度,不会旋转方向去补偿错误。

希望变换后依然满足 n'·t'=0。对任意表面切线,代入 t'=At,可写成 n'ᵀAt=0。令 n'=A⁻ᵀn,就有 n'ᵀAt=nᵀt=0。这份推导依赖 A 可逆,并且讨论的是同一个局部表面的切线空间。

对角缩放的逆转置就是逐轴取倒数,因而当前问题只需先计算 n/s,再归一化。旋转与均匀正缩放恰好让位置方向变换和法线变换具有相同方向,容易让错误实现长期留在代码里,直到某个实例被拉长或压扁。

探针只实现可审计的对角缩放

源码在 .tmp/daily-labs/2026-10-23-1930/NormalScale.cs,调用链为 Program → NormalScale.Transform → Validate。完整核心实现如下,使用 System.Numerics.Vector3 的逐分量除法:

using System.Numerics;

public static class NormalScale
{
    public static Vector3 Transform(Vector3 normal, Vector3 scale)
    {
        Validate(normal);
        Validate(scale);
        if (normal.LengthSquared() < 1e-12f ||
            MathF.Abs(scale.X) < 1e-4f || MathF.Abs(scale.Y) < 1e-4f ||
            MathF.Abs(scale.Z) < 1e-4f)
            throw new ArgumentOutOfRangeException(nameof(scale));
        return Vector3.Normalize(normal / scale);
    }

    private static void Validate(Vector3 value)
    {
        if (!float.IsFinite(value.X) || !float.IsFinite(value.Y) ||
            !float.IsFinite(value.Z) || MathF.Abs(value.X) > 1e4f ||
            MathF.Abs(value.Y) > 1e4f || MathF.Abs(value.Z) > 1e4f)
            throw new ArgumentOutOfRangeException(nameof(value));
    }
}

输入法线允许非单位长度,输出统一归一化。每个分量绝对值至多为 1e4,法线长度至少为 1e-6,三个缩放分量的绝对值至少为 1e-4。这些是本探针限制中间值范围的显式合同,不是引擎通用阈值;接入真实资产时应依据模型尺度、矩阵条件和误差预算决定拒绝策略。

零缩放会把三维空间压成低维表面,逆矩阵不存在。实现选择拒绝整个输入,不用任意默认方向补出一个结果。极小但非零的轴也被当前合同拒绝,避免把数值不稳定的方向伪装成有效数据。法线为零、非有限分量及超出数值范围的输入同样触发异常。

这段代码没有接收一般矩阵。如果对象包含剪切、父子组合形成的非正交线性部分,不能只读三个缩放值逐轴相除。此时应从实际对象到世界矩阵的线性部分计算逆转置,并以同一矩阵约定验证切线;对于列向量组合 A=RS,法线顺序为先逆缩放、再旋转。把矩阵存储顺序与向量乘法约定混在一起,会让形式上正确的公式仍落到错误实现。

从固定向量计算的等比例方向图,正确法线与切线垂直,错误路径点积为0.6

同一输入直接对照两条路径

固定案例的正确输出约为 (0.4472136,0.8944272,0),错误输出约为 (0.8944272,0.4472136,0),变换后单位切线约为 (0.8944272,-0.4472136,0)。正确输出与切线点积为 0,错误输出为 0.6。技术图使用这些真实输出,以相同横纵比例绘制。

Program.cs 的正常与失败回归刻意同时检查方向和长度:

Check("CorrectOrthogonal", MathF.Abs(Vector3.Dot(correct, transformedTangent)) < 1e-6f);
Check("WrongNormalizedStillTilted", Vector3.Dot(wrong, transformedTangent) > 0.59f);
Check("CorrectUnit", MathF.Abs(correct.Length() - 1) < 1e-6f);
Reject("ZeroScale", n, new(0, 1, 1));

当天以 .NET SDK 9.0.202、Release 配置执行:

dotnet build "$PROBE_PROJECT/NormalLab.csproj" -c Release
dotnet run --project "$PROBE_PROJECT/NormalLab.csproj" -c Release --no-build

构建退出码 0,零警告、零错误。运行退出码 0,通过 20 项、失败 0、跳过 0,程序计时 1246.5234 ms。另取 10,000 组固定输入:法线角度为 (i+0.5)·2π/10000,切线在 XY 平面与之垂直,X 轴缩放交替正负且幅值随 i%97 变化,Y 轴幅值随 i%53 变化。最大垂直性残差为 8.940697e-08,最大长度平方误差为 2.3841858e-07。耗时只记录本次运行,不表示引擎或 GPU 性能。

镜像缩放还会改变三角形朝向

负缩放没有被一概禁止。只要各轴非零,法线变换仍可保持垂直性,但镜像会改变三角形顶点绕序。对两条边 u、v,有 cross(Au,Av)=det(A)A⁻ᵀcross(u,v)。当行列式为负时,从变换后边重新叉乘出的朝向,与逆转置后的原法线方向相反。

探针以 (-2,1,1) 检查这项符号变化,断言两条归一化结果点积小于 -0.9999。这不是法线矩阵失效,而是表面朝向与垂直方向分属两项合同:渲染侧还需要确定镜像实例的剔除策略,若使用切线空间法线贴图,也要检查切线框架手性。本文没有实现光栅剔除或切线空间重建,不能用垂直性通过替代它们的验收。

对运行时架构而言,法线矩阵应由实际拥有世界变换的层生成,与位置矩阵保持同一版本;静态烘焙工具则应在修改位置的同一次提交里更新法线,并明确镜像后的绕序处理。逐顶点反复计算同一个实例的逆矩阵没有必要,可以在实例变换改变时更新后复用;这里未测上传与计算成本,不据此承诺帧率收益。

开篇的斜面问题可以落成一个直接的接入门禁:资产做非均匀缩放后,检查变换后法线是否仍垂直于对应切线,再独立检查镜像朝向。仅断言长度为 1 会放过本例中 0.6 的切向分量。把位置、方向与表面法线分开定义,才有条件判断应使用哪份变换,以及遇到退化实例时应拒绝、隐藏还是重新构建几何。