Base-法线贴图

法线贴图(Normal Map)用纹理保存表面法线方向,在不增加模型顶点和三角形的情况下,让光照表现出凹凸、刻痕、褶皱等细节。

它改变的是参与光照计算的法线,而不是真正修改几何体:

  • 能改变明暗、高光和反射的细节。
  • 不能改变模型轮廓。
  • 不能产生真实的遮挡关系和视差。
  • 物体投射的阴影通常仍然来自原始几何体。

因此法线贴图适合表现尺度较小的表面细节,轮廓、深缝和大幅度起伏仍然需要真实几何、视差映射或位移贴图。

法线贴图改变着色法线但不改变几何轮廓

1. 法线是什么

法线是垂直于表面的单位向量。光照计算中常见的漫反射为:

1
Diffuse = LightColor × BaseColor × max(dot(N, L), 0)
  • N:表面法线。
  • L:表面指向光源的方向。

法线方向发生变化,N · L 就会变化,像素的明暗也随之变化。镜面反射同样依赖法线,所以法线贴图还会改变高光与环境反射的形状。

模型本身已经有顶点法线。光栅化时,三个顶点的法线会被插值到三角形内部,再归一化后用于逐像素光照。法线贴图是在这个插值法线的基础上继续增加细节。

1.1 几何法线与着色法线

需要区分两类法线:

  • 几何法线(Geometric Normal):由三角形边叉乘得到,代表真实几何表面的朝向。
  • 着色法线(Shading Normal):由顶点法线插值并叠加法线贴图后得到,参与 BRDF 光照计算。

着色法线可以与几何法线不同,但不能无限偏离。偏差太大会产生漏光、明暗翻转、能量异常和高光闪烁。

2. 怎么把方向保存到纹理

单位法线的三个分量范围是 [-1, 1],普通纹理通道范围是 [0, 1],所以写入纹理时需要编码:

1
EncodedNormal = Normal × 0.5 + 0.5

Shader 采样后再解码:

1
Normal = EncodedNormal × 2 - 1

例如切线空间中没有任何扰动的法线为:

1
(0, 0, 1)

编码后的颜色为:

1
(0.5, 0.5, 1.0)

所以常见的切线空间法线贴图整体呈蓝紫色。

法线向量从负一到一编码到纹理颜色

法线贴图存的是向量数据,不是颜色。导入引擎时不能把它当作 sRGB 颜色纹理读取,否则 Gamma 转换会破坏向量方向。

3. 法线贴图保存在哪个空间

法线方向只有结合坐标空间才有意义。常见的法线贴图分为切线空间、模型空间和世界空间三种。

3.1 切线空间法线贴图

游戏中最常见的是切线空间法线贴图。

切线空间以表面每个顶点的局部坐标基为基础:

  • T:Tangent,切线,通常对应纹理 U 增大的方向,也就是局部 X 轴。
  • B:Bitangent,副切线,通常对应纹理 V 增大的方向,也就是局部 Y 轴。
  • N:Normal,顶点法线,也就是局部 Z 轴。

这三个方向组成 TBN 矩阵:

1
TBN = [ T  B  N ]

切线空间中的 TBN 坐标基

切线空间法线表示的是“相对于模型原有表面朝向的偏移”:

  • (0, 0, 1):沿原始法线方向,没有扰动。
  • (1, 0, 0):偏向切线 T
  • (0, 1, 0):偏向副切线 B

它的优点是:

  • 模型移动、旋转和变形后仍然可用。
  • 可以在结构相似的表面之间复用。
  • 适合骨骼动画和蒙皮模型。
  • 大部分方向集中在正 Z,压缩效果通常较好。

缺点是渲染端与烘焙端必须使用一致的切线基。如果切线生成方式、UV 方向或分裂规则不同,就容易出现接缝和阴影错误。

3.2 模型空间法线贴图

模型空间法线直接保存法线在 Object Space 中的方向,颜色通常比较丰富,不再整体偏蓝。

优点:

  • 不需要 TBN 转换。
  • 方向覆盖相对均匀,精度较稳定。
  • 对固定、不变形的模型可以得到较好的效果。

缺点:

  • 强依赖模型朝向,难以复用。
  • 模型发生非刚性变形后不再正确。
  • 镜像模型和骨骼动画处理困难。

因此它更适合静态物体,实时游戏中远少于切线空间法线。

3.3 世界空间法线贴图

世界空间法线保存固定在 World Space 中的方向。它几乎只适合不会旋转、不会变形的特殊对象或离线数据缓存。

屏幕空间效果中的 GBuffer Normal、法线缓存等也可能保存世界空间或视角空间法线,但这类纹理是渲染结果,不等同于美术制作的材质法线贴图。

4. TBN 矩阵

从法线贴图采样出来的是切线空间法线,而光照一般在世界空间或视角空间计算,所以需要把它转换到对应空间。

假设 TBN 都已经位于世界空间,并按列组成矩阵:

1
2
TBN = float3x3(T, B, N)
normalWS = normalize(mul(TBN, normalTS))

矩阵的行列约定和 mul 参数顺序依赖 Shader 语言与代码写法。不要只背乘法顺序,应该检查矩阵中保存的是基向量的行还是列。

4.1 副切线如何计算

模型通常只保存 Normal、Tangent 和一个手性符号,不必额外保存 Bitangent:

1
B = cross(N, T) × tangentSign

tangentSign 常保存在顶点 Tangent 的 w 分量中,通常是 +1-1。它用于处理 UV 镜像后切线空间手性翻转的问题。

有些引擎还会把模型变换的负缩放符号乘进来。忽略这个符号,镜像 UV 或负缩放区域的法线通常会翻转。

4.2 正交化

插值、蒙皮和非均匀缩放后,TN 可能不再互相垂直。可以使用 Gram-Schmidt 方法重新正交化:

1
2
3
N = normalize(N)
T = normalize(T - N × dot(T, N))
B = cross(N, T) × tangentSign

最后转换得到的法线也需要归一化。

4.3 法线为什么不能直接乘模型矩阵

位置可以使用 ObjectToWorld 矩阵转换,但法线在存在非均匀缩放时不能直接使用同一个矩阵,否则法线可能不再垂直于表面。

正确的法线变换矩阵是模型矩阵左上角 3×3 部分的逆转置:

1
2
NormalMatrix = transpose(inverse(ModelMatrix3x3))
normalWS = normalize(mul(NormalMatrix, normalOS))

切线是沿表面的方向,变换规则和法线并不完全相同。工程中优先使用引擎提供的法线、切线转换函数,它们通常已经考虑非均匀缩放和平台约定。

5. 一个完整的 Shader 过程

下面是简化后的 HLSL 风格伪代码。代码只表达整体过程,具体接口需要根据 Unity、UE 或自定义引擎调整。

5.1 顶点阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Attributes
{
float3 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 tangentOS : TANGENT;
float2 uv : TEXCOORD0;
};

struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float3 tangentWS : TEXCOORD2;
float tangentSign: TEXCOORD3;
};

Varyings Vert(Attributes v)
{
Varyings o;
o.positionCS = TransformObjectToHClip(v.positionOS);
o.normalWS = TransformObjectToWorldNormal(v.normalOS);
o.tangentWS = TransformObjectToWorldDir(v.tangentOS.xyz);
o.tangentSign = v.tangentOS.w * GetOddNegativeScale();
o.uv = v.uv;
return o;
}

5.2 像素阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float3 Frag(Varyings i) : SV_Target
{
float3 N = normalize(i.normalWS);
float3 T = normalize(i.tangentWS - N * dot(i.tangentWS, N));
float3 B = cross(N, T) * i.tangentSign;

float3 normalTS = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv).xyz;
normalTS = normalTS * 2.0 - 1.0;
normalTS = normalize(normalTS);

float3x3 tangentToWorld = float3x3(T, B, N);
float3 normalWS = normalize(mul(normalTS, tangentToWorld));

return EvaluateLighting(normalWS);
}

这里故意写出了解码过程。实际引擎中法线贴图可能使用 BC5、DXT5nm 等格式,通道并不一定直接对应 RGB,应使用引擎提供的 UnpackNormal 一类函数。

6. DirectX 与 OpenGL 法线格式

切线空间法线贴图常见两种 Y 方向约定:

  • DirectX 格式:通常认为绿色通道采用 Y- 约定。
  • OpenGL 格式:通常认为绿色通道采用 Y+ 约定。

两者最直观的转换方式是翻转绿色通道:

1
G = 1 - G

如果格式用错,凸起通常会看起来像凹陷,表面上下方向的受光会颠倒。

DirectX 与 OpenGL 法线贴图的绿色通道约定

但根本原因不是 API 名字,而是烘焙工具和渲染器对切线空间 Y 轴的约定不同。最终应该以目标引擎的导入设置和实际光照结果为准。

7. 法线贴图的制作与烘焙

常见流程是用高模向低模烘焙切线空间法线贴图:

  1. 低模准备正确的 UV、硬边和顶点法线。
  2. 高模提供倒角、刻痕等几何细节。
  3. 从低模表面沿 Cage 或射线方向采样高模表面法线。
  4. 把高模法线转换到低模的切线空间。
  5. 将结果写入低模 UV 对应的纹理像素。

法线贴图不是独立于模型的图片。烘焙结果依赖低模的:

  • UV 布局和镜像方式。
  • 顶点法线与平滑组。
  • 硬边位置。
  • 切线空间算法。
  • 三角形划分。

烘焙完成后如果重新修改这些数据,原法线贴图可能不再匹配。为了减少不同软件之间的差异,常使用 MikkTSpace 作为统一的切线空间算法,但烘焙工具与引擎仍然需要同时保持一致。

7.1 UV 接缝与硬边

UV 接缝处的切线方向可能不连续,因此顶点通常会被拆分。只要烘焙和渲染使用相同切线基,法线贴图可以补偿这种变化,让最终光照尽量连续。

常见经验是:

  • 几何硬边通常也应该拆 UV。
  • UV 拆分不一定必须成为几何硬边。
  • UV 岛之间要留足 Padding,避免 MipMap 采到其他岛的颜色。

把角度很大的几何折角全部设为平滑,会迫使法线贴图存储很强的反向补偿。这会降低有效精度,也更容易在低分辨率和 MipMap 下出现伪影。

7.2 三角化

四边形最终会被 GPU 渲染成三角形。不同软件如果选择了不同的对角线,顶点切线与插值结果可能变化。

对要求严格的资产,应该在烘焙前固定三角化,并让烘焙、导出和引擎使用同一份三角形拓扑。

7.3 Cage 与投射错误

Cage 决定低模向高模投射射线的范围和方向:

  • Cage 太小,会漏掉高模细节。
  • Cage 太大,可能投射到不相关表面。
  • 相邻部件过近,容易互相污染。
  • 深凹槽和尖角处容易产生射线交叉。

这些问题常表现为渐变扭曲、黑斑、细节断裂。它们不是靠提高纹理分辨率就能解决的,需要检查投射范围、分件烘焙或使用 Match by Name。

8. 导入、压缩与采样

8.1 关闭 sRGB

法线是线性向量数据。对法线贴图进行 sRGB 解码会改变各分量比例,导致方向错误。

在引擎中把纹理标记为 Normal Map,通常会自动设置正确的颜色空间、压缩格式和解码方式。

8.2 为什么常用两个通道

单位法线满足:

1
x² + y² + z² = 1

对于普通切线空间法线,通常只使用朝向表面外侧的半球,所以 z >= 0。只保存 X、Y 后,可以重建 Z:

1
z = sqrt(max(1 - x² - y², 0))

因此 BC5 常用两个独立通道保存 X、Y,通常比把三个分量放入普通颜色压缩格式更适合法线数据。

采样后的向量仍建议归一化,因为纹理过滤、压缩误差和分量重建都可能让长度偏离 1。

8.3 MipMap 与远距离闪烁

MipMap 会对相邻法线做平均,但多个单位向量的线性平均结果通常不再是单位向量:

1
length(average(N1, N2, ...)) <= 1

简单归一化可以恢复方向,却会丢失该区域法线分布的方差。远距离下,高频法线细节可能导致高光闪烁或材质显得过亮、过光滑。

可用的处理包括:

  • 使用正确的法线纹理压缩和 Mip 生成方式。
  • 控制法线细节强度与频率。
  • 使用 Toksvig、LEAN Mapping 或基于法线方差的粗糙度修正。
  • 生成 Mip 时把消失的法线变化转移到 Roughness 中。

最后一种思路也叫 Specular Anti-Aliasing:细小法线变化在远处无法直接显示时,应该让高光变宽,而不是继续产生亚像素闪烁。

9. 法线强度

调整法线强度不能简单地对整个 RGB 颜色相乘。常见做法是放大切线空间法线的 XY 偏移,再重新构造或归一化:

1
2
3
normalTS.xy *= strength;
normalTS.z = sqrt(saturate(1.0 - dot(normalTS.xy, normalTS.xy)));
normalTS = normalize(normalTS);

也有实现保留原 Z,再做归一化:

1
normalTS = normalize(float3(normalTS.xy * strength, normalTS.z));

两种方法的强度曲线并不完全相同。strength = 0 时应该得到 (0, 0, 1),而不是零向量。

过强的法线会让着色法线接近与几何表面平行,产生黑边、漏光、反射拉伸和闪烁。大尺度起伏应该交给几何或位移,不应该全塞进法线贴图。

10. 多张法线贴图如何混合

角色或地形材质经常需要把基础法线与细节法线叠加。直接平均两张法线会削弱细节,也不符合旋转组合的含义。

最简单的近似是 Whiteout Blend:

1
2
3
4
float3 BlendNormal(float3 n1, float3 n2)
{
return normalize(float3(n1.xy + n2.xy, n1.z * n2.z));
}

质量要求更高时可以使用 RNM(Reoriented Normal Mapping)。它把细节法线重新定向到基础法线的局部表面上,比线性相加更能保持两者强度。

实际项目中优先使用引擎提供的法线混合节点,尤其要确认输入是否已经解码到 [-1, 1]

11. 双面材质与背面法线

双面渲染时,背面使用的几何法线、切线基和法线贴图都需要统一翻转规则。只翻转 N 而不处理 TBN 的手性,可能让背面凹凸方向错误。

常见处理方式是根据 FrontFace / VFACE 判断正反面,再由引擎统一调整切线空间或世界空间法线。不同引擎的双面法线模式不同,需要结合材质设置确认。

12. 法线贴图与其他凹凸技术

12.1 Bump Map / Height Map

高度图保存标量高度。Shader 可以对高度求导,得到局部坡度,再转换成法线。

  • 高度图容易绘制和组合。
  • 法线贴图直接存方向,能表达更复杂的局部细节。
  • 从高度生成的法线必须是可积的,而手工法线不一定对应真实高度场。

12.2 Parallax Mapping

视差映射根据观察方向偏移 UV,让纹理细节看起来具有深度。它通常仍然配合法线贴图完成光照。

它能产生一定视差,但一般仍不改变真实轮廓和几何阴影。POM 等高级方法可以模拟局部自遮挡,但成本更高。

12.3 Displacement Mapping

位移贴图真正移动顶点或细分后的几何表面,因此可以改变轮廓、遮挡和阴影,成本也更高。

一个常见的尺度分工是:

  • 大尺度形状:真实几何或位移。
  • 中尺度凹凸:法线贴图。
  • 高频微小变化:法线细节与 Roughness。

13. 与 PBR 的关系

法线贴图修改的是 PBR 中使用的着色法线 N,会影响:

  • N · L:直接光漫反射。
  • N · VN · H:镜面 BRDF。
  • 环境贴图的反射方向。
  • GBuffer 中写入的法线。
  • SSAO、SSR、延迟灯光等依赖法线的屏幕空间效果。

它没有直接改变材质的 Roughness。两者描述的尺度不同:

  • Normal Map:可被纹理分辨率表达的中高频表面方向变化。
  • Roughness:更微观、无法逐条解析的法线分布范围。

如果法线贴图包含很强的高频变化,却仍使用极低 Roughness,远距离很容易出现高光闪烁。两者需要一起匹配。

着色法线偏离几何法线后,普通 BRDF 还可能产生非物理的能量增益。一些渲染器会使用 Shading Normal Correction、限制法线朝向或其他能量补偿方法,但具体实现依赖渲染管线。

14. 常见问题与排查

14.1 凸起看起来像凹陷

优先检查绿色通道是否需要翻转,也就是 DirectX / OpenGL 的 Y 方向约定是否匹配。

14.2 模型上出现明显接缝

检查:

  • 烘焙器与引擎的切线空间是否一致。
  • 模型导入后是否重新计算了 Normal 或 Tangent。
  • UV 镜像区域的 Tangent Sign 是否正确。
  • 硬边是否拆分了 UV。
  • UV 岛 Padding 是否足够。
  • 烘焙前后模型是否保持相同三角化。

14.3 整体光照方向很怪

检查:

  • 法线贴图是否被当成 sRGB 读取。
  • 是否忘记从 [0, 1] 解码到 [-1, 1]
  • TBN 的矩阵行列与 mul 顺序是否正确。
  • 法线、灯光和观察方向是否位于同一坐标空间。
  • 世界空间法线是否归一化。

14.4 镜像 UV 的一半方向错误

检查 Tangent 的 w 手性符号、Bitangent 的叉乘顺序,以及负缩放符号是否参与计算。

14.5 移动或骨骼动画后法线错误

切线空间的 TN 必须和顶点位置一起完成蒙皮,之后重新归一化、正交化并构造 B。模型空间法线贴图通常不适合非刚性变形。

14.6 远处高光闪烁

检查法线是否过强、细节频率是否过高、MipMap 是否正确生成,以及是否需要 Specular AA 或 Roughness 修正。

14.7 法线贴图看起来完全没效果

检查:

  • 材质是否真的采样并使用了该法线。
  • 网格是否具有有效 UV、Normal 和 Tangent。
  • 法线强度是否为 0。
  • 贴图是否接近默认法线色 (0.5, 0.5, 1.0)
  • 场景光照和 Roughness 是否足以显示表面方向变化。

15. 调试方法

排查法线问题时,可以按下面顺序观察中间结果:

  1. 直接显示法线贴图,确认通道和 UV 正常。
  2. 显示解码后的 normalTS * 0.5 + 0.5
  3. 分别显示世界空间 TBN
  4. 显示最终 normalWS * 0.5 + 0.5
  5. 使用一个可移动的方向光,只观察 dot(N, L)
  6. 临时关闭法线贴图,对比原始顶点法线。
  7. 检查网格导入信息中的 Normal、Tangent、UV 和三角形数量。

还可以使用几张简单测试纹理:

  • 纯色 (0.5, 0.5, 1.0):结果应与顶点法线一致。
  • X 方向倾斜法线:验证 Tangent。
  • Y 方向倾斜法线:验证 Bitangent 和绿色通道。
  • 带凹凸文字或圆球的标准测试图:快速判断整体约定。

16. 总结

法线贴图的完整链路是:

法线贴图从烘焙到参与 PBR 光照的数据链路

1
2
3
4
5
6
7
8
9
高模表面方向
↓ 烘焙到低模切线空间
纹理编码 [0, 1]
↓ 线性采样、格式解压
切线空间法线 [-1, 1]
↓ TBN 转换并归一化
世界空间或视角空间着色法线

参与 PBR、反射与屏幕空间效果

最核心的几个点:

  1. 法线贴图只改变着色,不改变真实几何轮廓。
  2. 切线空间法线依赖 TBN 和手性符号共同定义。
  3. 烘焙器与引擎必须使用一致的切线基、三角化和通道约定。
  4. 法线贴图属于线性数据,不能按 sRGB 颜色读取。
  5. 采样、插值和转换后的法线都要注意归一化。
  6. 法线强度、压缩、MipMap 和 Roughness 会共同影响远距离稳定性。
  7. 遇到错误时,从切线空间法线、TBN 到最终世界空间法线逐级可视化,通常比直接猜材质参数更有效。