镜像变换烘焙进 Mesh 后,法线正确为何仍会被剔除
把一块三角形面板沿 X 轴镜像,再把变换烘焙进顶点,法线仍然正确指向 +Z,面板却可能在背面剔除开启后消失。重新计算光照、把法线乘上 -1,都未必修复这个问题:光栅化阶段判断正反面所依据的顶点绕序,已经和材质拿到的法线分离了。
这里固定一个容易复算的案例。源三角形的 A、B、C 分别是 (0,0,0)、(1,0,0)、(0,1,0),索引为 0,1,2,外向面法线为 +Z。镜像采用 Scale(-1,1,1),烘焙完成后对象变换回到单位矩阵。目标是保留原先定义的外侧,并继续使用同一套正面约定;若引擎另有镜像补偿,需要先明确它是否仍然参与绘制。

图中的方向箭头只表达两个通道需要分别处理,具体坐标、索引和正负号以下面的数据图为准。
一次镜像改变了哪一个方向
镜像后 A 与 C 不动,B 变成 (-1,0,0)。保留索引时,cross(B-A,C-A) 从 +Z 变成 -Z。原来的外向法线经过逆转置却仍为 +Z,因为这次变换没有改变 Z 分量。因此,“法线正确”与“当前索引仍面向外侧”是两条不同判断。
这不是需要逐轴猜测的特殊情况。把线性变换写成列向量形式的 L,两条边为 u、v,有 cross(Lu,Lv) = det(L) L^(-T) cross(u,v)。可以把两侧与任意 Lw 点乘:两者都得到 det(L) dot(cross(u,v),w);L 可逆时,这足以确定向量相等。逆转置保留法线的垂直关系,行列式符号额外决定有向面积是否翻转。
所以,负行列式要求交换一个三角形的两个索引,正行列式保持原序。双轴负缩放的行列式为正,若代码只检查“是否存在负缩放”,会多翻一次。父子变换组合还可能带来旋转与剪切,因此判据必须使用真正烘焙的完整线性变换,不能只读某个节点的 X 缩放。

在烘焙入口同时交付顶点、法线和索引
本次实现位于研究工程的 MeshBake.cs,入口是 MeshBake.TryBake。它只处理一个平面三角形,源面法线直接由源绕序计算,因而不承担修复原始资产内外侧标注的职责。System.Numerics 使用行向量约定,位置调用 Vector3.Transform(p,m),法线调用逆矩阵的转置;这与上面的列向量公式表达同一个变换,不能在搬运公式时再无意转置一次。
下面是完整核心实现。返回值是尚未提交的候选,失败时没有可替换结果。位置、法线、索引通过同一个返回对象交给调用方,避免先修改顶点、后发现矩阵无效的半更新状态。
using System.Numerics;
public sealed record BakedTriangle(Vector3[] Positions, int[] Indices,
Vector3 Normal, float Determinant);
public static class MeshBake
{
public static bool Finite(Vector3 v) =>
float.IsFinite(v.X) && float.IsFinite(v.Y) && float.IsFinite(v.Z);
public static bool TryBake(Vector3 a, Vector3 b, Vector3 c,
Matrix4x4 m, out BakedTriangle? candidate)
{
candidate = null;
// 当前切片只接受仿射变换和有限的非退化三角形。
float[] values = { m.M11, m.M12, m.M13, m.M14,
m.M21, m.M22, m.M23, m.M24, m.M31, m.M32, m.M33, m.M34,
m.M41, m.M42, m.M43, m.M44 };
if (!values.All(float.IsFinite) || m.M14 != 0 || m.M24 != 0 ||
m.M34 != 0 || m.M44 != 1 || !Finite(a) || !Finite(b) || !Finite(c))
return false;
Vector3 sourceNormal = Vector3.Cross(b - a, c - a);
float sourceLength = sourceNormal.Length();
float determinant = m.GetDeterminant();
if (!float.IsFinite(sourceLength) || sourceLength < 1e-6f ||
!float.IsFinite(determinant) || MathF.Abs(determinant) < 1e-6f ||
!Matrix4x4.Invert(m, out Matrix4x4 inverse)) return false;
Vector3[] positions = { Vector3.Transform(a, m),
Vector3.Transform(b, m), Vector3.Transform(c, m) };
Vector3 normal = Vector3.TransformNormal(sourceNormal / sourceLength,
Matrix4x4.Transpose(inverse));
float normalLength = normal.Length();
if (!positions.All(Finite) || !float.IsFinite(normalLength) ||
normalLength < 1e-6f) return false;
normal /= normalLength;
int[] indices = determinant < 0 ? new[] { 0, 2, 1 } : new[] { 0, 1, 2 };
Vector3 geometric = Vector3.Cross(positions[indices[1]] - positions[0],
positions[indices[2]] - positions[0]);
float area = geometric.Length();
if (!float.IsFinite(area) || area < 1e-6f ||
Vector3.Dot(geometric / area, normal) < 0.999f) return false;
candidate = new BakedTriangle(positions, indices, normal, determinant);
return true;
}
}
这里有意公开数值边界:长度与行列式的 1e-6 是当前输入尺度下的拒绝阈值,各自的量纲不同,不能当作通用条件数检测。源叉积长度对应两倍面积,矩阵行列式对应体积缩放;一个很小但形状良好的模型也可能被本切片拒绝。输出还检查有限性、面积与方向对齐,防止溢出或精度损失生成不可解释的候选。接入不同单位或极端缩放的资产时,应重新定义尺度相关容差,必要时提高计算精度,而非直接删除失败分支。
候选的数组仍是可变数据。这里的整体提交只说明调用方在验证成功后替换引用,不意味着它是线程安全的不可变快照;实际 Mesh 上传若异步执行,还必须保证候选在上传完成前不被修改。
让错误索引留在回归里
Program.cs 的 Main → Bake → TryBake → Alignment 覆盖正常调用链,unchanged-indices-regression 刻意保留错误绕序,要求它与正确法线相反。这个反例比只检查函数返回成功更有区分力:删除交换索引分支,会直接破坏已知输入的方向合同。
调用方保持旧 Mesh 的检查同样经过真实失败返回,而非只观察异常文字:
var active = identity;
if (MeshBake.TryBake(A, B, C, invalid, out var candidate)) active = candidate!;
Check("failure-keeps-active", ReferenceEquals(active, identity) && candidate is null);
其中 identity 是单位变换候选,invalid.M41 为 NaN。测试还包括单轴、双轴、三轴镜像,旋转、平移、剪切、非均匀缩放,以及奇异矩阵、过小行列式、非仿射矩阵和重复顶点。矩阵网格由四个 X 缩放、四个 Y 缩放、三个 Z 缩放与十二个旋转角组合,共 576 组;每组都验证索引交换与行列式符号一致,并检查几何法线对齐。
2026-09-10 使用 .NET SDK 9.0.202、net9.0 Release 执行。$PROBE_DIR 指向包含上述三个源码文件的研究工程目录:
cd "$PROBE_DIR"
dotnet build MeshLab.csproj -c Release
dotnet run --project MeshLab.csproj -c Release --no-build
构建退出码 0,警告 0、错误 0,耗时 9.54 秒;运行退出码 0,决定性输出如下:
passed=17 failed=0 skipped=0 samples=576 duration_ms=9.9392
det=-1 wrong_indices=012 wrong_cross_z=-1 normal_z=1 corrected_indices=021 corrected_alignment=1
minimum_alignment=0.9999999
运行耗时是本次 CPU 功能检查的墙钟记录,不是 Mesh 吞吐量或 GPU 性能结论。576 组有限输入也不能证明任意病态矩阵都可稳定求逆。当前结果证明的是已声明输入下的方向修正、明确拒绝与旧结果保留,尚未执行 Unity、Godot 或其他渲染后端的剔除和像素回读。
翻转只能由一处负责
如果最终绘制仍保留负行列式对象变换,渲染器可以在绘制状态中补偿正面约定;如果变换已经烘焙进顶点并从对象上移除,原来的运行时判据就未必还能看见这次镜像。本例选择让烘焙入口交付已经修正索引的 Mesh。合批后若又按源对象镜像标记翻转剔除状态,就把同一补偿执行了两遍。
使用固定投影和视口约定时,翻转三角形索引会翻转它的屏幕绕序;具体哪一面被称为正面,仍由后端状态决定。上线验收应固定相机,分别渲染源网格、镜像烘焙网格和故意错误索引,回读覆盖像素,再验证开启与关闭背面剔除的差异。不能把 CPU 的 alignment=1 直接写成画面已经通过。
把材质改为双面可以遮住消失现象,却没有修复几何方向合同。法线贴图还需要切线与手性的配套变换,本实现没有输出切线,也不覆盖蒙皮或逐顶点非线性变形。对这里的静态仿射烘焙,交付条件已经足够具体:用实际矩阵确定方向奇偶性,在同一入口处理顶点、法线和索引,并让下游清楚这份 Mesh 已经完成了哪一次补偿。