非均匀缩放后的法线:直接乘世界矩阵会失去垂直关系
一个三角面的顶点位置和法线一起乘上世界矩阵,位置看起来正确,光照却像表面被压出了一道斜坡。把法线重新归一化也没有修复:长度回到 1,不代表它仍垂直于变换后的表面。
本篇把输入固定为两条局部切向量 e1=(1,0,0)、e2=(0,1,1),局部法线由它们的叉积归一化得到。线性变换同时包含 X 方向二倍缩放、XY 剪切和 Z 方向四分之一缩放。矩阵采用列向量约定,长度使用同一世界单位;平移不会影响方向,因而不进入验证。

主图只表达垂直关系的分歧;具体向量、点积和镜像结果以下方固定数据为准。
归一化只能修长度,不能修方向
设局部表面上一条切向量为 t,法线 n 满足 n^T t=0。位置的线性部分记为 A,变换后的切向量是 At。若法线也直接变为 An,需要 (An)^T(At)=n^T A^T A t=0。只有当 A^T A 不改变这组正交关系时才成立;旋转与统一缩放属于这种情况,任意非均匀缩放或剪切并没有这个保证。
要让新法线 n' 对所有变换后切向量继续满足 n'^T At=0,可以令 n'=A^(-T)n。代入后得到 n^T A^(-1)At=n^Tt=0。随后归一化只移除逆转置带来的长度变化,不参与恢复正交性。
固定样本中,逆转置结果与两条世界切向量的点积都为 0;直接变换并归一化后,与第二条切向量的点积为 -1.036535。它不是微小舍入误差,而是方向合同已经改变。法线贴图后续再精细,也无法补回顶点阶段提交的错误基底。

代码按余子式写清矩阵约定
研究工程的调用链是 Program.cs → NormalTransform.TryApply → M.Det。入口只接收仿射矩阵的 3×3 线性部分和一个非零法线;输出是单位法线。实现直接展开逆转置的余子式,避免先构造逆矩阵后又因行列存储约定误转置。以下是参与本次构建和测试的完整核心文件。
namespace NormalLab;
public readonly record struct V(double X, double Y, double Z)
{
public bool Finite => double.IsFinite(X) && double.IsFinite(Y) && double.IsFinite(Z);
public double Length => Math.Sqrt(Dot(this, this));
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 v, double s) => new(v.X * s, v.Y * s, v.Z * s);
public static double Dot(V a, V b) => a.X*b.X + a.Y*b.Y + a.Z*b.Z;
public static V Cross(V a, V b) => new(
a.Y*b.Z - a.Z*b.Y,
a.Z*b.X - a.X*b.Z,
a.X*b.Y - a.Y*b.X);
public V Unit() => this * (1 / Length);
}
public readonly record struct M(
double A00, double A01, double A02,
double A10, double A11, double A12,
double A20, double A21, double A22)
{
public bool Finite => new[] { A00,A01,A02,A10,A11,A12,A20,A21,A22 }.All(double.IsFinite);
public double Det =>
A00*(A11*A22-A12*A21) - A01*(A10*A22-A12*A20) + A02*(A10*A21-A11*A20);
public V Apply(V v) => new(
A00*v.X + A01*v.Y + A02*v.Z,
A10*v.X + A11*v.Y + A12*v.Z,
A20*v.X + A21*v.Y + A22*v.Z);
}
public static class NormalTransform
{
public static bool TryApply(M linear, V normal, out V transformed)
{
transformed = default;
double d = linear.Det;
if (!linear.Finite || !normal.Finite || normal.Length == 0 ||
!double.IsFinite(d) || Math.Abs(d) < 1e-12) return false;
// 直接计算 A 的逆转置乘以法线,避免混淆行列约定。
V value = new V(
(linear.A11*linear.A22-linear.A12*linear.A21)*normal.X +
(linear.A12*linear.A20-linear.A10*linear.A22)*normal.Y +
(linear.A10*linear.A21-linear.A11*linear.A20)*normal.Z,
(linear.A02*linear.A21-linear.A01*linear.A22)*normal.X +
(linear.A00*linear.A22-linear.A02*linear.A20)*normal.Y +
(linear.A01*linear.A20-linear.A00*linear.A21)*normal.Z,
(linear.A01*linear.A12-linear.A02*linear.A11)*normal.X +
(linear.A02*linear.A10-linear.A00*linear.A12)*normal.Y +
(linear.A00*linear.A11-linear.A01*linear.A10)*normal.Z) * (1/d);
if (!value.Finite || value.Length == 0) return false;
transformed = value.Unit();
return transformed.Finite;
}
}
TryApply 对非有限输入、零法线和绝对行列式小于 1e-12 的矩阵返回 false。这个阈值是本探针为适中输入设置的策略,不是可以直接复制到任意世界尺度的常数;生产实现应根据矩阵来源、精度和允许的条件数确定拒绝边界。
这里也没有在失败时返回原法线。奇异变换可能把一个三角形压成仍有面积的面,也可能压成线或点。前一种情况可以从仍存活的世界切向量重新叉乘,后一种情况根本没有唯一表面法线。只看原法线无法区分这两类结果,因此降级策略必须由拥有变换后几何的边界决定。
镜像保持正交,却改变有向表面
逆转置解决的是法线作为协向量与切平面的关系。它不自动替调用方处理负行列式带来的绕序翻转。对任意可逆 A,有 cross(Ae1,Ae2)=det(A) A^(-T) cross(e1,e2)。当 det(A)<0,从变换后顶点绕序重算的几何法线,与单纯逆转置得到的法线方向相反。
本次 X 镜像样本的行列式为 -1,逆转置法线与两条世界切向量仍正交,但它和重算几何法线的点积为 -1。这不是法线矩阵失效,而是两个接口选择了不同的朝向来源。若渲染管线通过翻转三角形绕序保持正面定义,法线、切线手性和剔除状态必须在同一提交中协调;若材质明确使用双面法线,则又是另一份合同。只把法线乘以 sign(det(A)),却不处理绕序和切线 W,仍可能留下背面剔除或法线贴图方向错误。
固定测试还验证了纯旋转时直接变换与逆转置相同,并扫描 8×8×8 组正缩放组合。执行命令与决定性输出如下,$LAB_ROOT 表示本篇研究工程目录:
cd "$LAB_ROOT"
dotnet build NormalLab.csproj -c Release --nologo
dotnet run --project NormalLab.csproj -c Release --no-build
build: exit=0 warnings=0 errors=0 elapsed=9.49s
run: exit=0
correctDotE2=0.000000 directDotE2=-1.036535 mirrorDot=-1.000000
passed=526 failed=0 skipped=0 samples=526 elapsedMs=7.6357
运行耗时包含进程启动、断言和 JSON 写入,不是渲染性能基准。测试没有接入具体引擎的矩阵上传、蒙皮、切线空间或着色器精度,也不能据此声称 GPU 成本可忽略。
修复位置应靠近几何提交边界
若对象矩阵在一帧内稳定,可以在 CPU 或常量缓冲构建法线矩阵,而不必为每个顶点重复求逆。若蒙皮先在骨骼空间混合位置与法线,法线矩阵还要与实际采用的蒙皮算法和空间一致;不能把对象矩阵的逆转置套在错误阶段。顶点着色器随后又做位移时,原法线是否仍描述最终表面也要单独验证。
开篇的斜光问题应先检查正交性,而不是继续调粗糙度或归一化次数。对于可逆仿射变换,逆转置给出了保持切平面关系的方向;负行列式要求同时明确绕序和切线手性;奇异变换则必须交给拥有变换后几何的代码决定重算法线、保留旧值还是拒绝绘制。三种情况分开,法线矩阵才是一份可执行的接口合同,而不只是着色器里的习惯写法。