说到法线贴图很多朋友的第一反应是“一张偏蓝紫色的图贴上去模型就多了细节”。这个认知没错但如果你只停留在“贴图”这个层面后面会遇到一堆莫名其妙的问题接缝处光亮突变、同一张贴图在部分模型上凹凸反向、烘焙出来的法线贴图在移动端闪得没法看。这篇文章我打算把法线、顶点法线、法线贴图这三件事从头捋一遍再把一个比较实用但容易被忽略的进阶操作——法线贴图转位移贴图——的原理和做法也拆开讲清楚。内容主要面向刚接触实时渲染的美术、技术美术和初级图形程序员争取让你读完就能直接上手排查问题。1. 先从法线说起渲染里这个向量到底在忙什么1.1 面法线和顶点法线别急着画等号在计算机图形学里法线Normal的本质就是一个垂直于表面的向量它决定了“这个表面朝向哪里”。但“表面”这个词在三维模型里有两种最常见的载体一个是三角面Primitive一个是顶点Vertex于是就有了面法线和顶点法线的区分。面法线最好理解。一个三角形有三个顶点取任意两条边做叉积得到的结果就是垂直于整个三角形平面的向量也就是这个面的法线。面法线最大的问题在于它只能描述“一整块平面的朝向”一旦遇到曲面用面法线做光照就会发现每个三角形的明暗都是独立跳变的。打个比方一个被切割成很多小面的球体光照上去像是钻石而不是光滑的球这种效果在低精度模型上尤其明显。顶点法线Vertex Normal则是定义在顶点上的方向向量。它不一定是某个面的法线而是由这个顶点相邻的所有面的法线综合计算出来的。之所以要引入顶点法线是因为GPU在光栅化三角形时需要对每个像素计算法线方向如果只有面法线三角形内部就只有一个固定方向完全无法表达平滑过渡。而有了顶点法线GPU就能通过插值让三角形内部的法线从三个顶点的法线渐变过来这样球体即使只用很少的三角形光照也能显得平滑。1.2 顶点法线怎么来平均、加权与平滑组顶点法线最常见的生成方式是“对相邻面法线取平均”。听起来简单但这里有一个容易被忽略的细节是简单平均还是面积加权平均简单平均的处理方式是所有相邻面法线一视同仁加起来除个数面积加权平均则让面积更大的三角形贡献更大的权重。两种方式在大多数情况下视觉差异不大但在一个顶点连接了面积悬殊的两个面时加权平均的结果会更合理。实际上大多数DCC软件如Maya、3ds Max的“软边/自动平滑”功能背后走的都是类似的法线平均逻辑但具体算法可能用到“顶点到面中心的距离”“面夹角”等额外因素所以同一个模型在不同软件里自动生成的顶点法线会有细微差异。这引出一个重要的概念平滑组Smoothing Group。同一个顶点位置上如果模型被分到了两个不同的平滑组这个顶点就会同时保存两套不同方向的法线。之所以这么做是为了在模型上同时保留“光滑曲面”和“硬边”两种效果。比如一个圆柱体侧面需要平滑过渡但顶盖和侧面交界的边缘线需要是清晰的硬边那么交界处的顶点就应该分到不同平滑组让法线产生“断开”的效果。这也是后面法线贴图工作流里容易出问题的根源之一——顶点法线如果和UV边界、硬边没有配合好烘焙出来的法线贴图就会出现接缝。1.3 光栅化之后像素法线如何插值模型经过顶点着色器后GPU会把三角形拆成一个个片元也就是像素级别的碎片。此时顶点法线已经跟随顶点进入了光栅化阶段接下来要做的就是三角形内部的插值。插值方式通常是用重心坐标Barycentric Coordinates简单说就是根据片元在三角形里的位置计算它相对三个顶点的权重然后用权重把三个顶点的法线混合起来。这一步是逐像素执行的所以模型面数越高、三角形越小插值出来的法线方向就越逼近理论上真实的曲面法线。这里需要记住一个关键点实时渲染中我们操作的绝大多数法线不管是顶点法线还是插值出来的片元法线最终都会参与光照计算。光照模型比如经典的Blinn-Phong、GGX把法线、视线方向、光源方向放进去算出一个像素的亮暗。所以法线只要偏一点光照效果就会变化。理解了这条链路你就能明白法线贴图本质上是在做什么——它就是在“片元法线插值完成之后”这个环节手动把法线方向再改一改让光照以为表面是凹凸不平的。2. 法线贴图是怎么“骗”人的2.1 低模、高模与细节欺骗做实时渲染时模型面数是有预算的。移动端一个角色可能只有两三万面PC端也不能无限加面。但砖墙的缝隙、地砖的接缝、皮肤上的毛孔、铠甲上的划痕这些细节如果全部建模面数立刻爆炸。法线贴图Normal Map的核心思路就是“不改变几何改变光照计算时的法线方向”。具体做法是先做一个包含丰富细节的高模再做一个低模用于实际渲染。烘焙阶段我们让高模的细节形状“投射”到低模上生成一张记录“低模法线应该往哪个方向偏才能骗过光照”的贴图。等到渲染时低模本身的几何不变面数一分不多但每个像素的法线都按照贴图里的值被扰动了一下光照计算出来的结果就像真的有凹凸细节一样。这是一种典型的视觉欺骗效果极好代价极低。代价通常只有一张纹理贴图以及一个采样操作这也是法线贴图为什么能成为现代渲染管线里最基础、使用最广泛的一张贴图。2.2 切线空间法线贴图的“本地坐标系”法线贴图里的法线方向到底存在哪个坐标系里这是理解法线贴图最核心的一点。把法线方向直接存在世界空间World Space里可以吗可以但会有致命问题如果模型旋转或者移动贴图里存的方向就不对了光照会错乱。把法线存在物体空间Object Space里稍好一些但模型一旦变形比如角色动画的骨骼蒙皮方向又不对了而且同一张贴图很难复用到不同形状的模型上。所以行业里通行的方案是把法线存到“切线空间”Tangent Space。切线空间是一个相对于模型表面局部建立的坐标系三根轴分别是切线Tangent、副切线Bitangent/Subnormal和法线Normal简称TBN。其中方向主要由UV决定UV的U方向对应切线方向V方向对应副切线方向法线方向自然就是顶点法线方向。这样定义之后无论模型怎么旋转、怎么摆放切线空间里的法线方向都能跟着模型一起变化光线计算结果始终正确。切线空间还有个巨大的好处贴图可以复用。同一张砖墙的法线贴图可以贴在墙面、地面、斜坡上不需要为每个朝向单独生成一张。这对于资产复用和工作流简化来说是决定性的优势。2.3 RGB编码你以为存的是颜色其实是向量法线贴图本质上是一张纹理纹理的每个像素有R、G、B三个通道范围是0到1。但是一个法线向量的xyz分量范围是-1到1所以需要做一个简单的映射存储值 向量分量 * 0.5 0.5 向量分量 存储值 * 2 - 1举个例子一个完全没扰动的法线在切线空间里是(0, 0, 1)映射到RGB就是(0.5, 0.5, 1.0)。这也是为什么法线贴图整体看起来是蓝紫色的原因红色通道和绿色通道都在0.5附近也就是128左右而蓝色通道接近1也就是255。只有那些凹凸强烈的地方红绿通道才会偏离0.5较多表现出明显的色彩。理解了编码方式再去看一些渲染代码就非常清楚了。很多Shader里会写vec3 normalTex texture(normalMap, uv).rgb; vec3 normalTS normalTex * 2.0 - 1.0;这两行做的事就是把颜色解码成真正的法线向量。顺带一提如果这两步之后发现光照方向反了经常是因为贴图里Y轴方向与引擎/API的约定不一致这个后面专门讲。2.4 为什么法线贴图是蓝紫色的这个问题值得单独说一嘴因为很多人会对“为什么大部分法线贴图看起来几乎一样”感到困惑。原因就是上一节说的编码方式。一般来说表面的凹凸细节相比整个模型表面来说是非常微小的所以法线扰动的幅度不会太大。也就是说真正有用的信息主要集中在x和y分量的小幅偏移上z分量始终接近1。映射到颜色通道后B通道长期保持接近255的高亮度RG通道在128附近小幅波动整体自然就是一片蓝紫。如果你看到一张法线贴图红红绿绿、颜色非常花通常说明它记录的不是切线空间的小幅偏移要么是物体空间法线贴图要么就是贴图制作时“强度”拉得特别大。颜色越花的法线贴图通常渲染出来反而容易出现不自然的光照。2.5 TBN矩阵把切线空间法线转到世界空间Shader里采样到切线空间法线之后不能直接拿去做光照因为光照计算需要在一个统一空间里进行。最常见的选择是世界空间World Space或观察空间View Space。不管选哪个都需要一个矩阵把切线空间里的向量变换过去这个矩阵就是TBN矩阵。TBN矩阵的构建并不复杂。每个网格顶点的属性里除了位置、法线、UV通常还会带上切线Tangent和副切线Bitangent这就是TBN的三根轴。顶点着色器里把这三根轴从模型空间变换到目标空间然后用它们构造矩阵// 顶点着色器里计算TBN vec3 T normalize(normalMatrix * tangent.xyz); vec3 B normalize(normalMatrix * bitangent.xyz); vec3 N normalize(normalMatrix * normal); mat3 TBN mat3(T, B, N);然后在片元着色器里把采样解码后的切线空间法线乘上TBN矩阵// 片元着色器里使用 vec3 normalTex texture(normalMap, vUv).rgb; vec3 normalTS normalize(normalTex * 2.0 - 1.0); vec3 normalWS normalize(TBN * normalTS);这里有一个隐藏的坑如果模型的UV存在镜像或者拉伸副切线B的方向可能在渲染时算错。很多引擎的Mesh导入设置里有一个“Calculate Tangent / Bitangent”选项还会提供“Swap Tangents”相关参数就是因为不同引擎对UV坐标系左/右手系以及切线的计算约定不一致。如果模型经常出现半边正常半边怪异的光照很可能就是副切线方向在某些区域反了。3. 法线贴图的制作流程与工程选型3.1 高模烘焙从高模到低模法线贴图最常见的制作方式是“烘焙”Baking。流程大致是在DCC软件如Maya、Blender里建好高模再做一个低模然后导入烘焙工具我常用的是Substance Painter、xNormal和Marmoset Toolbag让高模的细节映射到低模的UV上。烘焙的原理是“沿法线方向投射”。烘焙工具从低模表面的每个点出发沿低模法线方向或Cage边界方向发射射线射线命中高模表面时记录高模表面的法线方向再转换到低模的切线空间写进贴图。这里有个很重要的参数叫Cage包裹壳它定义了射线发射的范围。Cage太大容易把远处无关的高模细节也采进去Cage太小又会漏掉细节。实际使用中遇到高模低模偏差较大的区域比如裙摆、飘带需要单独调整Cage。烘焙出来的法线贴图建议检查三个地方接缝处是否连续、红绿通道是否有异常的突变区域、法线细节是否集中在蓝紫色基准附近。如果发现大面积红绿色块先别急着怀疑烘焙把模型搞坏了先检查低模的顶点法线是不是平滑的因为法线贴图烘焙相当依赖低模的顶点法线质量。3.2 从灰度高度图生成法线贴图除了高模烘焙另一种非常常用的方式是“从高度图Height Map生成法线贴图”。高度图是一张灰度图亮度代表高度。它和法线贴图的关系在数学上非常紧密法线其实是高度场的梯度方向。简单来说高度图变化越剧烈的地方法线偏离基准方向就越大。基于这个关系我们可以用卷积算子最常见的是Sobel算子求出每个像素在x和y方向上的梯度再通过梯度构造法线向量。加上Sobel卷积之后高度图转法线贴图就是几行代码的事import numpy as np from scipy import ndimage def height_to_normal(height, strength1.0): h height.astype(np.float32) / 255.0 sobel_x np.array([[-1,0,1],[-2,0,2],[-1,0,1]], dtypenp.float32) sobel_y np.array([[-1,-2,-1],[0,0,0],[1,2,1]], dtypenp.float32) gx ndimage.convolve(h, sobel_x) gy ndimage.convolve(h, sobel_y) normal np.dstack([-gx * strength, -gy * strength, np.ones_like(h)]) norm np.linalg.norm(normal, axis2, keepdimsTrue) normal / np.maximum(norm, 1e-6) return (normal * 0.5 0.5) * 255.0这段代码是简化版但核心逻辑就是梯度越小法线越接近(0,0,1)梯度越大法线越倾斜。实际工具比如Photoshop的法线贴图滤镜、Substance Painter的Height To Normal节点还会提供Strength参数本质上是给梯度乘一个系数控制凹凸强弱的整体缩放。需要提醒的是强度参数调得太高法线会过度倾斜渲染时容易出现硬朗的“塑料感”而且光照在高频区域会变得很碎。3.3 压缩格式与导入设置法线贴图是“数据贴图”不是“颜色贴图”这导致它在压缩和导入设置上有非常多的坑。先看压缩格式。BC1DXT1是很多引擎里贴图的默认压缩格式但它每个像素只存1bit的Alpha而且颜色精度有限。对法线贴图来说BC1压缩会严重损失法线精度尤其是B通道对应法线的z分量压完之后会出现明显的色块和光照不平滑。行业里通常推荐用BC5RG通道各8bit存储法线贴图。BC5的思想是只存RG通道B通道在Shader里根据“法线必须归一化”的性质重建即z sqrt(1 - x*x - y*y)。这样既省了带宽又保证了精度。PC平台上BC5已经很普及移动端则看硬件支持情况ASTC也可以用于法线贴图。格式通道法线贴图适用度说明BC1/DXT1RGB, 1bit Alpha差B通道精度太低不推荐BC3/DXT5RGBA一般Alpha可用来存高度/光滑度但B通道仍不够好BC5/RGTCRG高法线贴图首选格式BC7RGBA较高质量高适合高质量PC平台ASTC支持较高移动端常用需按质量档位测试再就是sRGB开关。颜色贴图需要sRGBGamma空间但法线贴图是线性数据导入时必须把sRGB选项关掉。如果忘记关法线贴图的数值会被错误地做一次非线性变换渲染出来法线方向会发生偏移最典型的表现是阴影和高光位置整体偏移细节看起来“脏脏的”。很多朋友遇到法线贴图效果不对第一反应以为是烘焙问题结果查了半天发现是导入设置里这个开关勾上了。还有一个容易被忽视的问题是Mipmap。法线贴图在远处会缩小GPU会自动使用较低层级的Mipmap但简单的平均采样会让法线向量变短导致远处的法线贴图effect衰减。严谨的做法是使用“法线贴图专用的Mipmap生成算法”比如对法线重新归一化后再平均或者在Shader里对采样结果重新normalize。很多现代引擎已经内置了这个处理但如果是自己写渲染器务必留意。4. 法线贴图转位移贴图把“假凹凸”变成“真几何”4.1 为什么要转位移贴图法线贴图有一个天然的限制它只是“欺骗光照”并没有真正改变几何形状。所以当视线掠过一个法线贴图表面时轮廓线依然是低模的平滑轮廓看不到砖块边缘的锯齿形起伏阴影也不会因为真正的凸起而产生遮挡。这种情况下如果想让细节真正“立起来”就需要位移贴图Displacement Map也叫高度贴图。位移贴图存的是高度值。渲染时读取高度图把顶点位置或片元位置沿法线方向偏移几何形状就真的改变了。配合曲面细分Tessellation或者像素级视差效果可以得到非常真实的凹凸轮廓。但美术资源里通常只有法线贴图没有带高度信息的高度图那么“法线贴图转位移贴图”就有实际需求了。另一个常见场景是在PBR工作流里Substance Painter等软件生成的高度图被压缩后丢失了最后只保留法线贴图需要反向重建高度图。4.2 重建高度的基本原理既然法线贴图是把高度图经过梯度计算得到的那么反过来从法线贴图重建高度图就是一个“从梯度场恢复标量场”的积分问题。用最简单的思路理解假如高度函数是h(u,v)那么法线贴图里的x分量和y分量近似对应h在u和v方向上的梯度∂h/∂u和∂h/∂v。我们拿到梯度之后沿着u方向做累加就能恢复出h的部分特征沿着v方向做累加也能恢复一部分。把两个方向的积分结果融合就得到一张大致接近原始高度图的结果。这里有一份极简的Python示例演示这个思路import numpy as np def normal_to_height(normal_map): n normal_map.astype(np.float32) / 255.0 * 2.0 - 1.0 n / np.maximum(np.linalg.norm(n, axis2, keepdimsTrue), 1e-6) # 从法线推导梯度符号约定取决于坐标系的Y轴方向 gx -n[..., 0] / np.maximum(n[..., 2], 0.1) gy -n[..., 1] / np.maximum(n[..., 2], 0.1) # 沿两个主轴分别积分 hx np.cumsum(gx, axis1) hy np.cumsum(gy, axis0) h (hx hy) * 0.5 h - h.min() h / h.max() return (h * 255.0).astype(np.uint8)这是一个示意性的实现实际工程中不会只用这么简单的算法因为直接积分会带来两个严重问题一是误差和噪声会沿着积分方向累积出现“拖影”和低频漂移二是法线贴图丢失了绝对高度信息所以积出来的结果只能是一个“相对起伏”无法确定绝对高度。所以严谨的做法是用泊松方程重建再用频率域滤波手段分离低频漂移之后做边界平滑最后用低通滤波把高频噪声压下去。市面上成熟的工具基本都内置了这套处理流程。4.3 转换工具与参数经验如果你不想自己写重建代码实际工作中可以直接用现成工具。Substance Painter有现成的滤镜节点“Height from Normal”。直接用即可但要注意输出高度图的位深。16位会比8位好很多因为高度图对精度非常敏感8位容易在平滑渐变区域出现断层。Photoshop NVIDIA Texture Tools这个老插件里有一个Normal to Height功能处理流程简单适合单张贴图快速转换。使用前把法线贴图摆正保证Y轴方向符合插件预期。xNormal虽然主要是烘焙工具但也提供了Normal to Height的批处理功能适合同时处理大量贴图。CrazyBump老牌工具转出来的高度图细节不错但软件已经很久不更新新系统上兼容性一般。工具参数方面最核心的是“高度强度”和“平滑度”。高度强度决定生成的高度图黑白差异的范围建议先输出一个小强度版本看看轮廓再逐步增加。平滑度则用来抑制重建过程中产生的噪声太高会把细节磨平太低则会让低模偏移时出现抖动。结合我的经验强度建议控制在0.4到0.7之间平滑度视贴图分辨率而定一般取中档偏高的值。4.4 转换时的注意点转换过程中有几个容易被忽视的坑我实际踩过列出来供参考。第一法线贴图必须是切线空间。如果拿到的法线贴图是物体空间Object Space或世界空间生成的转换工具根本没法正确重建出来的图是一团乱。判断方法很简单切线空间法线贴图以蓝紫色为主物体空间法线贴图颜色非常丰富几乎没有蓝紫基调。第二Y轴方向问题。不同软件生成的法线贴图在绿色通道上的正负约定可能不同DirectX方向和OpenGL方向。转换之前先确认工具预期的是哪种约定否则生成的高度图会上下颠倒表现为凹凸反向。第三转换成位移贴图后需要重新“包裹”。位移贴图真正用于渲染时如果偏移太大会穿帮尤其是边缘轮廓。所以实际项目中位移量通常要控制在一个很小的范围内比如模型尺寸的0.3%到1%左右而法线贴图强度往往比位移强度更稳最终效果需要两者一起调整。5. 常见问题排查实录5.1 接缝和镜像切空间的两大杀手法线贴图最常见的问题是UV接缝处出现“硬线”或光照跳变。原因多半是UV展开时把原本连续的表面切开了导致接缝两侧的切线方向不连续TBN矩阵在这个位置发生突变。渲染时接缝两侧的法线经过TBN变换后朝向不一致光照自然不连续。另外一种是镜像问题。很多模型为了节省UV空间会把左右对称的部分重叠在一起共享UV但左右两侧的副切线方向其实是相反的。如果不处理重叠区域总有一侧的法线贴图效果是反的表现为“半边鼓起来半边凹下去”。解决方式有几种一是在烘焙时让UV拆开处理不共享镜像区域二是模型导入引擎时勾选“计算副切线符号”相关的选项把Mirror方向的UV自动翻转B通道三是在Shader里对UV的镜像方向做判断翻转副切线。5.2 DX/OpenGL法线方向差异法线贴图的绿色通道在不同图形API下的约定不一样。DirectX体系里法线贴图的Y轴约定通常与OpenGL相反所以同一张法线贴图在Unity的OpenGL/Android平台和Windows平台上有时候会出现凹凸反向。排查方法很直接显示一个法线贴图效果明显的球体如果凹凸明显是“反向”的直接把绿色通道取反或者用引擎里的“Flip Green Channel”选项。不要靠记忆每个项目都值得花几分钟验证一下因为法线贴图的来源不同约定不一定与你的引擎默认值一致。5.3 压缩与采样带来的问题压缩过度是另一个高频问题。法线贴图用BC1压缩后不仅出现色块还会让法线贴图的B通道精度骤降导致z分量重建后偏差很大光照会出现“网格状”噪声。纠正做法是改用BC5/ASTC并同时在导入设置里关掉不必要的“抖动”Dithering选项。另外法线贴图在某些引擎里会被默认当成颜色贴图进行sRGB解码表现是整体颜色变亮、细节对比度下降、凹凸感很弱。排查时在材质编辑器里看看采样法线贴图的时候是否用了texture(...).rgb而没有做线性化处理以及导入设置里是否勾掉了sRGB。5.4 法线贴图看起来“没效果”的排查思路如果贴了法线贴图但效果很弱甚至没效果按这个顺序排查检查贴图是否真的采样到了给材质加一个调试颜色把法线贴图直接输出到屏幕看是否是一片蓝紫。检查解码是否正常normalTex * 2 - 1是否执行了还是直接拿0到1范围的颜色去当法线。检查TBN矩阵是否归一化如果TBN矩阵里的向量没有归一化法线会被拉长或压缩直接导致光照强度异常。检查强度参数是否过小有些材质系统会给法线强度一个系数默认值可能是0拉到1以上再试试。检查光照方向如果场景只有环境光没有直接光或者法线贴图作用的光照模型里根本没使用法线那自然看不出效果。6. 个人实操心得与小技巧最后分享几个我平时在项目里经常用的检查方法和处理套路。第一个技巧是“看颜色判断贴图类型”。拿到一张贴图如果是蓝紫色为主大概率是切线空间法线贴图如果五颜六色像抽象画可能是物体空间法线贴图如果直接是灰度图那是高度图或者光滑度图。这个判断在资源审查和排查问题时非常快几秒钟能定位错误资产。第二个技巧是“法线贴图强度调整的正确做法”。很多美术希望“法线弱一点”会把贴图整体除以一个数或者压对比度但最稳妥的方式是在Shader里做插值把解码后的法线向量向(0,0,1)方向靠拢vec3 normalTS texture(normalMap, uv).rgb * 2.0 - 1.0; normalTS.xy * normalStrength; normalTS normalize(normalTS);这样调整的是法线的倾斜程度不会破坏贴图本身的分布结构。直接在图片编辑器里降饱和很容易让贴图在边缘处出现反光不自然的问题。第三个技巧是“法线贴图和位移贴图同时存在时位移强度要克制”。如果做的是PC端高画质项目同时使用法线贴图和位移贴图位移的高度量一定不要设太高。曲率和边缘往往不是绝对对称的位移过度会在模型轮廓上产生明显的锯齿感反而暴露低模的真实精度。法线负责“视觉质感”位移负责“轮廓和阴影遮挡”两者分工不同强度不一定要保持一致。第四个技巧是“多用材质实例参数少直接改贴图”。法线强度、UV精度这些尽量暴露成材质参数在场景里微调而不是反复回DCC软件重新导出。尤其是法线贴图转位移贴图的时候换一个强度参数就要重新生成一次贴图不如在材质层做非破坏性调整。这些技巧总结起来其实就一句话法线贴图不是玄学它背后就是编码、坐标系和解码这三件事。把切线空间、TBN矩阵、通道编码和压缩格式这四件事搞明白绝大多数据法线贴图相关的问题都可以在几分钟内定位到根因。希望这篇文章能帮你省下一些熬夜排查的时间。