· 6 分钟阅读 · 2380 字

模型只换了三角形对角线,平滑法线为何偏转

两个互相垂直的单位正方形,位置和轮廓完全不动,只把其中一个面的三角形对角线翻转。在共享角点上,面积加权得到的平滑法线从 (0.707107,0,0.707107) 变成 (0.894427,0,0.447214),偏转约 18.435°。如果法线重建放在模型导入或运行时网格重剖分之后,同一块几何表面会得到不同的着色输入。

问题出在“哪些三角形对这个顶点有贡献”。整个正方形面积没有变化,但包含该顶点的三角形面积总和变了。本文用 C# 网格探针只检查法线方向;两个面刻意属于同一平滑组,用来分离权重算法的影响,不把这个直角当作必须磨圆的美术规范。

两块相交木板与对角线表达网格剖分的概念摄影,非实际测试现场

同一块平面,顶点收到的面积可以不同

令共享点为原点 O,XY 面的法向是 +Z,YZ 面的法向是 +X,两面的边长都是一。布局 A 的两条对角线都经过 O,每个面各有两个包含 O 的三角形,面积各为 0.5。把这些三角形的单位法向乘面积再求和,得到 Z+X,归一化后位于两法向的角平分线上。

布局 B 只翻转 XY 面的对角线。XY 面仍由两个三角形铺满,但其中一个不再包含 O;它不属于 O 的邻接集合。O 收到的 XY 面总面积降到 0.5,YZ 面仍为一,累积量于是变成 0.5Z+X。最后统一归一化只能改变长度,不能消除已经变化的方向比例。

这也解释了为什么检查“总面积守恒”找不到问题:守恒的是整个面的面积,法线算法消费的是某个顶点的关联面片。几何优化、三角化导出或程序化 Mesh 改了索引连接,即使没有移动顶点,也可能改变这一局部集合。下图把两个平面展开显示,着色部分表示实际进入 O 的累积项;右上未着色的三角形仍然存在,只是不再关联 O。

固定单位平面在两种剖分下的邻接三角形、面积角度权重及法线方向

角度加权改用每个三角形在 O 处占据的角度。布局 A 在每个面贡献 45°+45°,布局 B 的 XY 面贡献一个 90°,YZ 面仍为两个 45°。每个平面的单位法向没有变化,总角度也没有变化,所以两次累积都与 Z+X 同向。

这里可保持的是一个明确条件:同一个平面角域被不重叠地重新划分,子三角形绕序一致,并且没有跨越平滑组边界。它不推出任意曲面重网格后法线不变;曲面重新取样会改变面法向,塌边也可能改变顶点所见的角域。

权重必须乘在单位面法向上

对从 O 出发的两条有序边 a、b,叉积 c=a×b 给出方向和两倍面积。单位面法向是 c/|c|,角度取 atan2(|c|,a·b),范围在零到 π 之间。角度使用弧度;所有项同乘角度单位换算常数不会影响最终归一化方向。

若直接把角度乘到原始叉积上,权重会变成“角度再乘面积”,前面的面积偏置仍在。因此实现先除以叉积长度,再乘选定权重。面积对照路径使用叉积长度作为权重,两倍面积里的共同因子会在最终归一化时消失。

核心文件是 .tmp/daily-labs/2026-10-27-1930/CornerNormals.cs,调用链为 Program → CornerNormals.Angle / Area → Calculate。输入是已经属于同一个逻辑顶点、同一个平滑组的三角形角,不承担位置焊接和硬边识别:

using System.Numerics;

public readonly record struct Corner(Vector3 A, Vector3 B);

public static class CornerNormals
{
    public static Vector3 Angle(ReadOnlySpan<Corner> corners) => Calculate(corners, true);
    public static Vector3 Area(ReadOnlySpan<Corner> corners) => Calculate(corners, false);

    private static Vector3 Calculate(ReadOnlySpan<Corner> corners, bool angleWeighted)
    {
        if (corners.IsEmpty)
            throw new ArgumentException("Empty smoothing group.", nameof(corners));
        double sx = 0, sy = 0, sz = 0, total = 0;
        foreach (var corner in corners)
        {
            var a = corner.A;
            var b = corner.B;
            if (!Finite(a) || !Finite(b))
                throw new ArgumentException("Non-finite edge.", nameof(corners));
            double aa = Dot(a, a), bb = Dot(b, b);
            double x = (double)a.Y * b.Z - (double)a.Z * b.Y;
            double y = (double)a.Z * b.X - (double)a.X * b.Z;
            double z = (double)a.X * b.Y - (double)a.Y * b.X;
            double cross = Math.Sqrt(x * x + y * y + z * z);
            if (aa == 0 || bb == 0 || cross <= 1e-12 * Math.Sqrt(aa * bb))
                throw new ArgumentException("Degenerate corner.", nameof(corners));
            double weight = angleWeighted ? Math.Atan2(cross, Dot(a, b)) : cross;
            sx += x / cross * weight;
            sy += y / cross * weight;
            sz += z / cross * weight;
            total += weight;
        }
        double length = Math.Sqrt(sx * sx + sy * sy + sz * sz);
        if (length <= 1e-12 * total)
            throw new ArgumentException("Cancelling normals.", nameof(corners));
        return new Vector3((float)(sx / length), (float)(sy / length), (float)(sz / length));
    }

    private static bool Finite(Vector3 v) =>
        float.IsFinite(v.X) && float.IsFinite(v.Y) && float.IsFinite(v.Z);

    private static double Dot(Vector3 a, Vector3 b) =>
        (double)a.X * b.X + (double)a.Y * b.Y + (double)a.Z * b.Z;
}

叉积、点积和累积使用双精度,输入与最终法线仍是 Vector3。这样避免单精度中间乘积过早溢出;如果边向量在进入函数前已经因大坐标相减丢失精度,转成双精度不会恢复这些信息。调用方应先在合适的局部坐标中构造边。

退化判定比较 |a×b| 与 10^-12|a||b|,等价于检查夹角正弦是否过小,而非使用一个依赖模型尺寸的绝对面积阈值。空集合、零边、非有限值和近共线角直接拒绝;法向累积几乎抵消时也拒绝返回。这里采用严格导入检查策略,运行时若需丢弃坏三角形,应由上层记录剔除数量并重新判定顶点是否仍有有效贡献,不能默默返回零法线。

把翻转对角线固定成回归输入

Program.cs 的 layout-b-angle 与 layout-b-area 使用同一组边。YZ 面保持原剖分,XY 面在 O 处只留下一个角:

using System.Numerics;

var x = Vector3.UnitX;
var y = Vector3.UnitY;
var z = Vector3.UnitZ;
Corner[] corners = [new(x, y), new(y, y + z), new(y + z, z)];
Vector3 angleNormal = CornerNormals.Angle(corners);
Vector3 areaNormal = CornerNormals.Area(corners);
// angleNormal = (0.70710677, 0, 0.70710677)
// areaNormal  = (0.8944272, 0, 0.4472136)

cancelling-winding 则传入 [(X,Y),(Y,X)]:两项来自相同几何位置,但绕序相反,累积方向抵消,函数抛出 ArgumentException。这是输入拓扑需要修复的失败,不应被当作平滑算法的正常输出。硬边回归把两个平面分别提交,结果各自为 +Z 和 +X,验证平滑组分离仍然决定是否发生混合。

当天执行如下,$LAB_ROOT 为本文探针目录,运行环境为 .NET 9 Release:

$ cd $LAB_ROOT
$ dotnet build NormalLab.csproj -c Release --nologo
Build succeeded. 0 Warning(s), 0 Error(s)
exit code: 0

$ dotnet run --project NormalLab.csproj -c Release --no-build
passed=23 failed=0 skipped=0 samples=10000 elapsed_ms=144.9432
layout_a.angle=(0.70710677,0,0.70710677)
layout_a.area=(0.70710677,0,0.70710677)
layout_b.angle=(0.70710677,0,0.70710677)
layout_b.area=(0.8944272,0,0.4472136)
area_drift_degrees=18.434949362895896
max_normal_distance=8.4293696e-8
exit code: 0

输出按 run.json 字段整理。planar-fan-retessellation 使用固定种子 271027,把两个平面各自的 90° 角域分成一到十二个角,随机改变边长;10,000 组结果对角平分线的最大向量距离为 8.43×10^-8。断言容差是 2×10^-6,测量对象是归一化向量差,不是角度误差。通过的 23 项还覆盖了倒序遍历、反向绕序、极小与极大统一缩放及非法输入。耗时仅记录此次检查,不作为网格处理性能结论。

法线重建应排在平滑组确定之后

这次对照支持的工具链决策是:如果重剖分应保留平面角域的平滑方向,角度权重比关联三角形面积更符合这一要求。若资产本来就要求大面片主导角点方向,面积加权仍可作为明确的美术策略;选择应写进导入规则,不能由不同导出器的偶然三角化方式决定。

计算权重之前还要区分渲染顶点与逻辑平滑顶点。UV 接缝通常会复制顶点;若它应保持光滑,可按预先建立的平滑组汇总后,把结果写回这些副本。真正的硬边即使位置重合也应分组。仅按坐标相同就合并,会把装甲折线或盒体棱边磨圆;仅按渲染索引隔离,又可能让连续曲面的 UV 接缝显形。材质边界是否共享法线也应由资产合同指定。

每个顶点的累积开销与其关联角数量成正比,邻接构造与权重求解应分开。静态导入可一次建立分组并生成法线;动态网格若修改了邻接关系,必须使缓存失效。本文没有验证引擎内置重算法、切线生成器或实际高光图像,输出合同只到顶点法线。需要法线贴图时,切线与烘焙过程还必须使用同一套最终法线和接缝规则,否则单独修正这一步仍无法保证最终着色连续。