在三维渲染管线里,法线决定了光照计算的走向,一旦法线不连续,原本平滑的曲面会在渲染结果上出现生硬的明暗分界线。这个问题的成因多种多样,可能是建模软件导出时保留了硬边,可能是网格存在重复顶点导致顶点法线无法共享,也可能是三角面缠绕方向不一致让法线方向部分朝内部分朝外。要彻底解决它,需要同时做好法线平滑与一致性约束两件事。

一、法线不连续的根源在哪里
法线分为面法线和顶点法线两种。面法线由三角面三条边的叉积计算得到,方向取决于顶点绕序;顶点法线则是为了在光栅化阶段做插值而预先计算好的平滑法线,通常等于共享该顶点的所有面法线的某种加权平均。渲染时片元着色器拿到的是三个顶点法线插值后的结果,因此顶点法线是否平滑直接决定了表面光照是否连续。
法线断裂最常见的原因是网格中存在位置重复的顶点。比如一个立方体,六个面之间如果共享顶点,那么一个顶点会被三个互相垂直的面共用,面法线平均后方向变得毫无意义。建模软件的常规做法是把立方体的顶点拆开,每个面用自己独立的顶点,顶点法线直接取面法线,于是立方体呈现出清晰的硬边。这套机制本身是合理的,问题在于很多导出流程会把本应平滑的曲面也做了同样的拆分,比如一个球体的每个三角形都有独立顶点,渲染出来就成了一个个清晰可见的小平面,俗称“碎面感”。
另一个隐蔽的来源是缠绕方向不一致。假设某个模型在建模时经过多次布尔运算或手工缝合,部分三角形的顶点顺序可能顺时针、部分逆时针,直接叉积算出的面法线一半朝外一半朝内。这样的网格即便做了平滑,光照也会出现一半亮一半暗的诡异现象,而且背面剔除会随机剔除三角形,模型看起来像破洞百出。
二、顶点法线的平滑计算方法
最朴素的平滑方法是简单平均:遍历所有三角形,把每个面的单位法线累加到它的三个顶点上,最后统一归一化。这个方法实现简单,但有一个明显缺陷——长条形三角形会被低估。两个三角形共享一条边时,面积大的面理应对顶点法线贡献更大,而简单平均让一个大面和一个小面的话语权完全相同,在细碎三角形密集的区域,法线方向会被大量小面拉偏。
更推荐的做法是按面积加权,或者更精确地按顶点对面元的张角加权。面积加权在计算面法线时顺便乘以三角形面积,实现成本几乎为零,效果在绝大多数场景下足够好。张角加权(也叫角度加权)则对每个顶点计算其在所属三角形中的内角大小,用内角作为权重,数学上更符合参数曲面的微分几何定义,对极端拉伸的网格效果更稳定。下面是面积加权的伪代码实现:
// 面积加权计算顶点法线
std::vector<Vec3> vnormals(positions.size(), Vec3(0,0,0));
for (size_t i = 0; i < indices.size(); i += 3) {
Vec3 a = positions[indices[i]];
Vec3 b = positions[indices[i+1]];
Vec3 c = positions[indices[i+2]];
// 叉积结果的模长等于平行四边形面积,即三角形面积的两倍
Vec3 fn = cross(b - a, c - a);
vnormals[indices[i]] += fn;
vnormals[indices[i+1]] += fn;
vnormals[indices[i+2]] += fn;
}
// 统一归一化
for (auto& n : vnormals) {
float len = length(n);
if (len > 1e-8f) n = n / len;
}
注意这里刻意没有对叉积结果归一化后再累加,因为叉积的长度天然携带了面积信息,直接累加就等价于面积加权。此外还需要处理平滑角度阈值:并不是所有相邻面都应该平滑,比如一个倒角圆柱的侧面和端面之间通常希望保留锐利的分界。工程上的通用做法是引入一个夹角阈值,两个相邻面的法线夹角小于阈值才参与平均,超过阈值则在边界处拆分顶点,形成硬边。Blender 的 shade_smooth_by_angle、Unity 的导入设置里的平滑角,本质上都是这套逻辑。
三、法线一致性检查与修复
平滑之后还必须保证方向一致,否则前面所有工作都白费。一致性检查的经典算法是邻接传播:随机选一个种子三角形,认定它的法线方向为正确方向,然后沿邻接边向周围三角形扩散。如果邻居三角形与当前三角形共享的边在两个三角形中顶点顺序相反(一条边在一个三角形里是 A 到 B,在另一个里是 B 到 A),说明两者绕序一致,可以直接传播;如果相同,则需要翻转邻居的绕序。不断迭代直到整个连通区域都被处理完。如果模型有多个独立的连通块,需要对每个连通块分别执行这个过程。
镜像变换是一个高频踩坑点。当模型经过 scale = (-1, 1, 1) 这类负缩放时,行列式为负,直接用变换矩阵变换法线会导致法线方向整体翻转。正确的做法是用世界矩阵的逆转置矩阵去变换法线,并且在检测到负行列式时手动取反。很多引擎在着色器里用 transpose(inverse(mat3(model))) 处理这个问题,但负缩放带来的绕序翻转仍需在 CPU 侧或几何着色器中额外修正,否则正面剔除和法线朝向会同时出错。
最后是重复顶点合并。修复管线通常是:先按位置坐标对顶点做空间哈希或排序,把误差范围内的重复顶点索引到同一个顶点上;再做绕序一致性传播;最后计算平滑法线。如果模型需要硬边,合并时要带着属性一起判断——法线夹角超过阈值的顶点即使位置相同也不能合并。顺序不能颠倒,先合并再统一绕序可以避免大量重复计算,而先平滑法线再合并顶点会把硬边信息永久抹掉,只能重新建模。
四、工程管线中的实践建议
在资产管线中,建议把法线处理固定在导入阶段完成,而不是依赖建模软件的导出设置。不同 DCC 工具对平滑组、分裂边的实现细节差异很大,直接采信导出的顶点法线往往在跨软件协作时出现不一致。更稳妥的方式是只导出位置和索引,由引擎侧统一按“面积加权 + 平滑角阈值 + 硬边顶点拆分”的规则重新生成法线,这样规则可控、可复现,也方便针对低端设备调整平滑角来换取更少的顶点数。
对于程序化生成的几何体,比如地形、参数曲面细分结果,直接在生成时就按数学定义计算精确法线是最佳方案。以高度图地形为例,可以用中心差分近似梯度,法线取归一化的负梯度向量,这比事后对三角形网格做平均更平滑,也避免了地形边缘处法线断裂的问题。总之,法线不连续从来不是一个孤立 bug,而是网格数据质量、平滑策略和变换约定三方面共同作用的结果,把这三层都纳入统一的处理管线,才能从根上消除渲染结果里那些碍眼的接缝。