← ALL ARTICLES

法线贴图

全库目录221
本文目录12

一张法线贴图在烘焙器里正常,进引擎却出现接缝,往往是两端使用了不同切线基。纹理保存的是局部方向,运行时必须解码并沿同一基底还原,才能得到连续的世界着色法线。

方向基底

几何法线和着色法线

  • Geometric Normal 来自三角形几何,决定真实表面方向。
  • Vertex Normal 是顶点属性,可以跨三角形平滑。
  • Shading Normal 是最终用于 BRDF 的方向,可能由插值法线和 Normal Map 组合。

着色法线可能偏离几何法线。偏差过大时会产生能量、阴影终止线和背面漏光问题。

Tangent Space

切线空间使用三个基向量:

  • Tangent T\mathbf T:通常沿 UV 的 U 方向;
  • Bitangent B\mathbf B:通常沿 UV 的 V 方向;
  • Normal N\mathbf N:表面法线。

组成 TBN 矩阵后,可以在切线空间和世界/视图空间之间变换方向。

Bitangent 经常不直接存储,而是由:

B=sign⋅(N×T)\mathbf B=sign\cdot(\mathbf N\times\mathbf T)

重建。对象空间的 sign 记录镜像 UV 的手性。法线以对象到世界矩阵的逆转置变换,切线以其线性部分变换;若对象变换的行列式为负,重建世界副切线时还需乘该行列式的符号。

切线生成规则

Normal Map 烘焙时使用一套切线基,运行时如果用另一套,接缝和高光会不一致。MikkTSpace 是常用统一方案,但 DCC、烘焙器、引擎导入设置仍要确认是否一致。

硬边、UV 缝和镜像 UV 都可能导致顶点拆分。一个空间位置相同,不代表可以共享同一个 Normal/Tangent/UV 顶点。

方向解码与合成

Normal Map 解码

纹理通常把 [−1,1][-1,1] 的方向映射到 [0,1][0,1]。运行时解码后需要恢复 Z,或从压缩格式读取三个分量。

BC5 常保存 X、Y 两个通道,再计算:

z=max⁡(0,1−x2−y2)z=\sqrt{\max(0,1-x^2-y^2)}

这种方式给 X/Y 更多精度,适合切线空间法线。若 X/Y 因压缩落到单位圆外,需要 Clamp,否则会出现 NaN。

DirectX 和 OpenGL Normal Map 常在 Y 方向约定上相反。错误时凹凸会翻转。

世界法线重建

下面的 HLSL 机制函数约定输入来自线性 BC5_UNORM 纹理。BC5_SNORM 的读取值已是有符号分量,不能再乘 2 减 1。tangentWS.w 已合并 UV 手性与对象变换行列式符号;normalWS 非零,切线不与法线平行,这些条件由网格导入校验。

hlsl
float3 DecodeNormalWS(
    float2 packedXY,
    float3 normalWS,
    float4 tangentWS)
{
    float2 xy = packedXY * 2.0 - 1.0;
    float z = sqrt(saturate(1.0 - dot(xy, xy)));
    float3 normalTS = float3(xy, z);

    float3 N = normalize(normalWS);
    float3 T = normalize(tangentWS.xyz - N * dot(N, tangentWS.xyz));
    float3 B = cross(N, T) * tangentWS.w;
    return normalize(mul(normalTS, float3x3(T, B, N)));
}

Gram-Schmidt 步骤去掉切线在法线方向的插值误差。这是本文采用的正交基约定;烘焙端必须使用与它匹配的逆变换。MikkTSpace 同时强调插值方式、副切线重建位置与四边形三角化的一致性,仅在顶点使用同一种切线生成器还不足以保证结果一致。HLSL 的 float3x3(T,B,N) 按行构造矩阵,因此 mul(normalTS,TBN) 明确得到 normalTS.x*T + normalTS.y*B + normalTS.z*N。row_major 与 column_major 改变存储约定,不改变这个乘法的数学含义。

两通道编码的精度陷阱

“存 XY、开方求 Z”是最简单的两通道方案,但它的误差分布很不均匀,理解这一点才能判断何时够用、何时必须换方案。

问题出在 z=1−x2−y2z=\sqrt{1-x^2-y^2} 这个映射上。对它求导:

∂z∂x=−x1−x2−y2=−xz\frac{\partial z}{\partial x}=\frac{-x}{\sqrt{1-x^2-y^2}}=\frac{-x}{z}

z→0z\to0 时导数发散。也就是说,法线接近切平面(接近水平朝向)时,X/Y 上一个量化步长的误差会被放大成 Z 上的巨大误差。

法线接近+Z时,开方映射在X/Y方向的导数较小,不存在同样的切平面放大。平坦区域仍可能暴露压缩或低位宽的角度误差,但需要按格式与高光响应测量。

结论是:该编码在切平面附近对误差更敏感。对切线空间法线(大多接近 +Z+Z、且不需要表达背向)通常够用,因为误差主要体现为轻微的细节损失;但对需要覆盖整个球面的世界空间法线完全不适用——它连符号都无法表达。

方向编码选择

需要更好精度或完整球面覆盖时,有几类改进:

八面体映射。把球面投影到八面体再展开成正方形,精度分布远比半球投影均匀,且覆盖整个球面。延迟渲染的 GBuffer 可用它在两个分量中保存完整方向。

立体投影。这种映射在球面某个极点存在奇点,工程实现必须限定覆盖范围或处理该区域,不能笼统视为全方向的均匀编码。

提高位深。把两个通道从 8 位提到 16 位是最直接的办法。它不改变误差的分布形状,只是整体缩小——切平面附近仍然更敏感,但绝对误差可能已降到不可见。

选择依据是覆盖范围与精度需求:切线空间、8 位、只需半球时,朴素方案配合 BC5 的优质压缩通常足够;世界空间法线,或对法线敏感的低粗糙度镜面反射,应当换用八面体或提高位深。

验证方法是渲染一个粗糙度极低的金属球并缓慢旋转,高光边缘出现阶梯状跳变即为编码精度不足。另用一张纯平法线(全部朝 +Z+Z)检查是否出现色带,可以单独暴露中心区域的量化问题。

Normal 强度

若把法线看作高度梯度方向 normalize(float3(-dhdu,-dhdv,1)),强度 ss 对应 normalize(float3(n.xy*s,n.z)),即缩放斜率。把 XY 缩放后再用平方根重建 Z 是另一种方向控制,强度过大时会压到切平面,不能把两者当成同一公式。整个法线统一缩放后再归一化,方向保持不变。

细节法线混合

直接 normalize(lerp(n1,n2,t)) 可以做两个方向之间的过渡,但把 Detail Normal 叠加到 Base Normal 时,普通加法或 Lerp 容易压平细节。

重定向法线映射(RNM)先构造把 +Z+Z 旋到基础法线的最短旋转,再用这个旋转变换细节法线。这样平坦细节仍返回基础法线,平坦基础也能完整保留细节。下面的 HLSL 机制函数接收已解码的单位法线,基础法线限定在 nz≥0n_z\ge0,因而分母至少为 1。

hlsl
float3 BlendNormalRNM(float3 baseNormal, float3 detailNormal)
{
    float3 t = baseNormal + float3(0.0, 0.0, 1.0);
    float3 u = detailNormal * float3(-1.0, -1.0, 1.0);
    return normalize(t * (dot(t, u) / t.z) - u);
}

旋转结果可能落到负 Z 半球。若把它重新烘焙为只存 XY、固定正 Z 的格式,需要限制素材坡度或选择保留符号的编码。它也可能偏离几何法线过大,着色和阴影终止线仍需一起检查。

手性错误的方向反例

令世界N=(0,0,1)、T=(1,0,0),未镜像时B=cross(N,T)=(0,1,0)。切线法线(0,0.6,0.8)重建为(0,0.6,0.8)。若UV镜像使sign=-1,B应变成(0,-1,0),同一份切线分量于是指向(0,-0.6,0.8)。漏掉sign会把凸起斜率留在错误一侧。

对象负缩放还可能再翻转一次手性。UV符号与变换行列式符号相乘,两次翻转应抵消;只按模型是否负缩放去猜法线贴图绿色通道,会把几何基底问题与资产编码问题混在一起。先显示T/B/N,再检查贴图解码能把两者分开。

验证方法

显示T、B、N和解码结果,测试镜像UV、非均匀缩放、硬边与低粗糙度高光。先关闭贴图核对顶点基底,再逐步加入解码、强度和细节混合。完整球面编码以GBuffer布局为主归属。

本页的 CPU 检查 用独立的 Y 轴旋转计算核对 RNM,并检查两种平坦法线恒等情况。解码与混合函数另经 DXC 编译;镜像网格导入、GPU 采样和目标引擎画面尚未验证。

相关主题

参考资料