资讯动态

法线贴图、凹凸贴图、阴影贴图:原理、公式与渲染实战

发布时间:2026/10/16 20:19:50 来源:尧图企业网站定制
法线贴图、凹凸贴图、阴影贴图这三个词放在一起经常把刚接触计算机图形学的人绕晕。它们都叫“贴图”但有的在改变表面受光方式有的在记录深度信息有的则是同一张纹理在不同渲染阶段被反复使用。纹理映射的应用远不止贴一张彩色图真正拉开画面差距的往往是那些把几何信息编码进纹理、再用着色器解出来参与光照计算的技巧。这篇文章我就围绕法线贴图、凹凸贴图、阴影贴图这三块把原理、公式、代码片段和实操中容易被忽视的坑一次说清楚适合正在学图形学基础、或者想把渲染效果做细的开发者参考。先说一个很多教程不会明说的观点纹理映射的本质是“用纹理存储一个场”。颜色贴图存的是二维颜色场法线贴图存的是方向场阴影贴图存的是深度场。只要渲染时知道如何解码任何能被采样和插值的数据都可以塞进纹理。理解了这一点再去看法线贴图和阴影贴图思路就会完全不一样。1. 纹理映射不只有颜色一张贴图到底能编码多少信息1.1 漫反射贴图的局限光照响应骗不了人最常见的纹理映射就是给模型表面贴一张彩色图比如木箱表面的木纹、砖墙表面的砖块纹理。做法很简单模型带上UV坐标每个三角形顶点对应纹理上的一个二维坐标片元着色器里用UV去采样纹理颜色作为漫反射颜色。但只贴颜色有一个明显问题它改变不了表面的光照响应。假设一个立方体的六个面都是平面法线方向固定不管木纹贴图多么精细光照在上面依然是一个平整光滑的反射砖缝、木纹的凹凸感完全出不来。你可以用颜色贴图画出砖缝的线条但线条本身不会产生阴影也不会因为光照方向变化而有立体起伏。换句话说漫反射贴图只解决了“表面长什么样”没有解决“表面朝着哪些方向”。这就导出了一个关键思路如果能用纹理记录表面每个点的朝向变化哪怕几何网格还是那个平面光照计算时也按朝向变化去算平面就能“假装”有凹凸。法线贴图、凹凸贴图以及更进一步的视差贴图、位移贴图本质上都是围绕这个思路展开的。1.2 图案、高度、方向都能写进纹理关键是渲染时怎么“解码”纹理的每个纹素不一定只代表颜色。一张普通的RGB纹理可以拆成三个独立通道来用R通道存高度G通道存粗糙度B通道存金属度甚至法线的xyz也能编码进RGB。这只是数据布局的问题核心在于渲染时要有对应的解码逻辑。比如高度图存的是表面相对基准面的高低偏移灰度值越大越高。渲染时算出高度场的梯度就能推导出表面法线应该往哪个方向偏。再比如法线贴图直接在每个纹素里存一个单位法线的xyz分量采样后乘以二减一还原到[-1,1]区间再变换到世界空间参与光照。又比如阴影贴图在光的角度渲染一张只写深度的纹理主相机渲染时把片元变换到光源空间比较当前深度和纹理里存的深度就能判断这个点是否被遮挡。这些东西共同的特点是把原本需要大量几何顶点才能表现的细节压缩进了纹理。纹理本身是二维数组经过采样和插值后可以看作一个连续的标量场或向量场这比用几何去表达要便宜得多。2. 凹凸贴图用一张高度图扰动光照方向2.1 高度求导改法线从二维灰度推导三维朝向凹凸贴图是早期就出现的做法思路非常朴素既然法线决定光照那我就用一张高度图去“扰动”原本的法线方向。假设表面某点原本的法线是N高度图在UV坐标(u,v)处的高度值是h(u,v)。如果局部表面比基准面高了一点相当于这个点沿着法线方向被抬高了。抬高之后原本平坦的表面就变成了一个斜坡斜坡的朝向由高度变化率决定。具体做法是求高度场在u方向和v方向的偏导数然后修正法线float h0 texture(heightMap, vUv).r; float hR texture(heightMap, vUv vec2(texelSize.x, 0.0)).r; float hU texture(heightMap, vUv vec2(0.0, texelSize.y)).r; float dhdu (hR - h0) / texelSize.x; float dhdv (hU - h0) / texelSize.y; vec3 tangentSpaceNormal normalize(vec3(-dhdu, -dhdv, 1.0));这是最简化的切线空间写法。实际渲染中还需要把tangent、bitangent、normal构成的TBN矩阵考虑进去把扰动后的法线从切线空间变换到世界空间。但核心数学直觉就是这个高度变化快的方向法线就偏得厉害高度没有变化的地方法线保持原样。这里有个容易搞混的点上面对高度差求导得到的是法线扰动不是真正的几何位移。几何网格本身完全没动只是光照计算时假装表面变倾斜了。所以凹凸贴图没法改变轮廓也没法产生真实遮挡它只是在着色层面“欺骗”光照方程。2.2 为什么说凹凸贴图是“算术便宜、表达受限”的方案凹凸贴图的好处是省高度图只需要一个灰度通道非常轻量采样次数也就是两三次额外纹理读取。在那个纹理带宽还很金贵的年代用一张灰度图换表面细节性价比很高。但它有两个明显的表达上限。第一高度场是单值函数一个uv位置只能有一个高度所以无法表示悬垂、洞穴这种需要多个表面层叠的结构。第二高度变化是相对平滑的如果真实表面有很锐利的转折或者很深的凹槽靠一阶差分只能得到一个比较模糊的近似。画面里那种“贴上去”的塑料感往往就来自这里。另外还要注意凹凸贴图的扰动量跟高度图的数值范围、UV缩放都有关系。同一个高度图UV拉伸得越大梯度就越小效果越不明显反过来高度差太大法线会过度倾斜光照会显得很脏。这也是为什么很多人在调凹凸贴图强度时需要乘一个系数。2.3 从凹凸到法线直接存最终答案绕过世界复杂度既然高度场需要求导、还需要处理UV缩放带来的梯度变化那为什么不直接存一张图里面填好每个点的法线方向这正是法线贴图的思路凹凸贴图存原因法线贴图直接存结果。可以把法线贴图理解为一张已经做过“高度场求导法线扰动”计算的成果图。烘焙工具在高精度模型上算出每个纹素应该对应的法线方向编码进RGB然后低精度模型在渲染时直接采样不需要做差分也不需要关心高度图的数值梯度效果更稳定。说清楚这个进化关系你就能理解为什么现在提到“法线贴图”的人远多于“凹凸贴图”。凹凸贴图不是没价值而是它给出的中间量高度场需要二次处理才能变成渲染要用的法线而法线贴图把这个中间量也省掉了。不过在一些追求极简、不想引入复杂切线空间的场景里灰度高度图的轻量优势依然存在。3. 法线贴图与切线空间大多数坑都出在这一步3.1 RGB编码与解码为什么法线贴图偏蓝法线贴图的每个纹素存的是一个三维向量也就是切空间下的法线方向。为了把区间[-1,1]的浮点数塞进[0,1]的RGB通道需要做一次简单的线性映射// 编码把[-1,1]映射到[0,1] vec3 encodedNormal normal * 0.5 0.5; // 解码把[0,1]还原到[-1,1] vec3 decodedNormal texture(normalMap, vUv).xyz * 2.0 - 1.0;法线贴图看起来偏蓝是因为大部分表面的法线都大致指向局部z轴也就是原法线方向。z通道值接近1映射后接近1代表蓝色分量很高而x、y扰动相对小所以整体呈现蓝紫色。如果一张法线贴图整体发红发绿通常说明它的朝向约定不太对或者模型空间/切线空间搞混了。解码之后还要做一件事重归一化。纹理经过压缩、mipmap插值后法线长度很可能不再是1直接拿去做光照会导致亮度忽明忽暗。所以在片元着色器里应该再normalize一次确保它是单位向量。3.2 TBN矩阵的构建逻辑法线怎么从贴图“粘”到表面法线贴图里存的是切线空间法线但光照计算需要世界空间法线。这里必须把切线空间的方向变换到世界空间最常用的办法就是构建TBN矩阵。TBN指的是tangent切线、bitangent副切线、normal法线三个正交基。切线方向怎么求一种朴素的思路是看UV坐标沿u方向变化时顶点位置在三维空间里如何变化。对三角形来说假设三个顶点分别是p0、p1、p2对应的UV差分别是(dx1, dy1)和(dx2, dy2)可以通过解线性方程求出三角形的切线和副切线方向。实际生产里一般不会在片元着色器里现算而是在模型导出时把切线数据烘焙到顶点属性或者用业界常见的标准切线空间算法生成以保证UV接缝处的连续性。有了TBN之后法线贴图的采样结果就可以这样变换到世界空间vec3 normalTex texture(normalMap, vUv).xyz * 2.0 - 1.0; vec3 N normalize(vNormal); vec3 T normalize(vTangent); vec3 B normalize(vBitangent); vec3 worldNormal normalize(mat3(T, B, N) * normalTex);这里要注意TBN的三个基向量最好做一次正交化。因为模型的UV映射可能有拉伸直接拿原始tangent和bitangent参与矩阵运算会产生剪切误差导致光照出现不自然的扭曲。常见做法是在顶点着色器里用Gram-Schmidt正交化再把它传给片元着色器。3.3 烘焙、压缩与采样把高模细节搬到低模的完整链路法线贴图的最大应用场景是把高精度模型的细节“烘焙”到低精度模型上。基本流程是准备一个高模和一个拓扑相近的低模两个模型都要有合理UV布局然后在烘焙工具里做“从高模到低模的投影”计算把高模表面法线映射到低模UV对应的纹素里。这个流程有几个特别容易翻车的地方。第一UV接缝处法线容易断。低模的UV如果切成多个岛岛边缘的纹素需要从高模同一位置取法线但由于切线方向可能不连续法线贴图会在接缝处产生明显断裂。解决办法是尽量选择合理的切分位置避开视觉敏感区域并使用高质量切线空间算法。第二镜像UV会导致法线通道方向反。很多对称模型喜欢把左右两边共用同一块UV这样可以省纹理空间。但对法线贴图来说镜像之后x通道方向会反转直接采样会让右边凸起、左边凹陷。遇到这种情况烘培时需要单独处理镜像模型或者对镜像的一侧翻转法线贴图的某个通道。第三纹理压缩会造成精度损失。普通DXT5/ASTC压缩对颜色友好但对法线方向不够精确。很多引擎会选择专用的法线压缩格式比如BC5/ASTC的法线模式它把xy存下来、z由sqrt(1-x^2-y^2)推导能明显减少法线抖动。采样时还要注意法线贴图不要走sRGB空间它存的是坐标数据不是颜色应该当成线性纹理来采样否则数值会被伽马校正带偏光照看起来发灰发暗。4. 阴影贴图用光的视角渲染一张深度纹理4.1 两趟渲染流程光源视角的深度pass与主视角的阴影比较阴影贴图和前面的法线贴图思路完全不同。法线贴图是把表面细节编码进纹理阴影贴图则是利用“光源视角下的深度纹理”来回答一个问题当前片元是否能被光源直接看到整个流程需要两趟渲染。第一趟把相机放在光源位置朝光照方向看。这一趟不需要输出颜色只需要把场景深度写进一张纹理也就是我们说的shadow map。对平行光来说光源相机用正交投影场景里所有物体都从同一方向投影对点光源来说则要用立方体贴图记录六个方向的深度。第二趟回到主相机正常的渲染流程。每个片元算出它在世界空间的位置然后把它变换到光源空间的裁剪坐标系再把坐标从[-1,1]映射到[0,1]得到shadow map采样坐标vec4 shadowCoord lightViewProjection * worldPos; shadowCoord.xyz shadowCoord.xyz * 0.5 0.5; float closestDepth texture(shadowMap, shadowCoord.xy).r; float currentDepth shadowCoord.z; float bias max(0.005 * (1.0 - dot(worldNormal, lightDir)), 0.001); float shadow (currentDepth - bias) closestDepth ? 1.0 : 0.0;如果当前片元的深度大于shadow map里记录的最接近深度说明这个片元被挡在了某个物体后面处于阴影中否则它就被光源直接照亮。这就是阴影贴图最核心的判断逻辑。这里的关键点是阴影贴图并没有做任何“光线追踪”它只是利用了深度纹理的比较。所以理论上任何能渲染深度的图形程序都能用这套方案做阴影。4.2 阴影痤疮与Peter Panningbias背后的几何直觉阴影贴图刚跑通时第一个让人抓狂的问题就是阴影痤疮shadow acne本该平坦的表面上出现密密麻麻的明暗条纹。原因是深度纹理的离散采样和浮点精度造成的自遮挡。片元处于一个平面上但它算出的深度和shadow map里同一位置的深度往往存在很微小的误差误差方向随机一会儿正一会儿负于是表面被判定为“自己在挡自己”。解决思路很直接在比较深度时把当前深度往光源方向“让”一点也就是减去一个偏移量bias。但偏移量给大了又会出现Peter Panning物体看起来像脱离了阴影阴影整体变窄甚至消失仿佛物体飘在空中。真正的问题在于表面与光源的角度各不相同。正对光源的表面深度几乎没有梯度小bias就够了接近掠射角度的表面深度变化非常剧烈固定bias无法覆盖。实际工程里常用斜率相关的bias让偏移量跟随深度梯度变化。还有一种很实用的组合是normal offset沿法线方向把用于采样shadow map的坐标整体推离表面这样能大幅减少自遮挡又不会像纯深度bias那样产生“阴影缩小”的视觉问题。4.3 shadow map的采样边界坐标变换、Clamp与深度精度阴影贴图的uv坐标由光源裁剪空间换算而来。片元如果落在shadow map覆盖范围之外采样纹理时会越界。默认的重复采样会导致边缘出现奇怪的循环阴影最好是设置成ClampToBorder或者在外面直接判定为无阴影避免边缘拉出长条黑斑。深度精度也是阴影质量的关键。很多人在刚开始用阴影贴图时直接复用主相机那个24位深度缓冲区但shadow map最好用专门的浮点深度纹理并保持光锥体的近远裁剪面尽量紧贴场景。光锥体的near和far相差越大深度值在远端的区分度就越差阴影在远处会出现成片闪烁或断裂。提高纹理分辨率能缓解但真正根治还是靠后面要说的级联阴影。还有一点经常被忽略阴影贴图的第一趟渲染应该输出的是纯深度而不应受法线贴图影响。也就是说深度pass里不要采样法线贴图去做凹凸扰动因为法线扰动会改变面片朝向的深度分布导致自遮挡加剧。这是不少项目里阴影抖动一直调不好的原因之一。5. 把阴影做干净PCF、级联阴影与我的调参经验5.1 PCF软阴影为什么要在比较结果上滤波而不是在深度纹理上滤波原始阴影贴图只有一个纹素大小就是0或1的判断所以阴影边缘呈锯齿状。最简单的软化方案是百分比靠近滤波Percentage Closer Filtering。实现方式是对shadow map周围多个纹素分别做深度比较然后对比较结果求平均float shadow 0.0; vec2 texelSize 1.0 / shadowMapResolution; for (int x -1; x 1; x) { for (int y -1; y 1; y) { float depth texture(shadowMap, shadowCoord.xy vec2(x, y) * texelSize).r; shadow (currentDepth - bias) depth ? 1.0 : 0.0; } } shadow / 9.0;这里要特别提醒一个容易出错的概念不要试图先对深度纹理做双线性插值再去跟当前深度比较。深度值是非线性的直接插值得到的深度可能落在边缘物体之间结果会漏光或者边缘出现黑色脏边。PCF的精髓是“先比较、后滤波”滤波的目标是0/1的可见性结果而不是深度本身。有些图形API还提供硬件比较采样器可以把“深度比较滤波”一步完成效率更高。但即便有硬件加速滤波核的采样分布仍然很影响效果比如均匀网格滤波容易产生软件感改用泊松圆盘采样或者旋转采样核软阴影会更自然。5.2 级联阴影映射近处清晰、远处不漏光的工程方案固定分辨率shadow map面临一个矛盾场景很大时如果光锥体覆盖全部场景近处一个角色可能只占几个纹素阴影边缘全在抖如果把光锥体缩得很小覆盖近处远处又没有阴影。级联阴影贴图CSM就是为解决这个问题出现的。做法是把主相机的视锥体按深度切分成多个区域每个区域用一份独立的shadow map分辨率保持一致甚至更精细。近处的级联覆盖范围小纹素密度高阴影自然清晰远处的级联覆盖范围大纹素密度低但视觉上也不会太在意。每次渲染片元时根据深度判断它落在哪个级联再采样对应的shadow map。级联之间容易出问题的地方是边界两个级联范围交界处如果采样结果不一致会出现一条明显的阴影分界线。常见做法是让两个级联的覆盖范围有重叠在重叠区域做一次插值混合让阴影从一级到另一级平滑过渡。5.3 实战中我会优先检查的三个问题说了这么多原理最后分享几个我在实际项目里踩出来的检查习惯。如果你的阴影贴图效果不对建议按下面顺序排查。第一先看shadow map本身是否正常。把深度纹理临时调试显示出来确认它没有全白、全黑或者出现奇怪的拉伸。很多时候阴影不对根源在光源矩阵算错了比如裁剪坐标没有正确映射到uv空间。第二看bias是否自适应。固定bias救不了掠射角度的表面只需要把bias表达式改成与法线-光照夹角、深度导数相关阴影痤疮和Peter Panning往往能同时缓解很多。再不行就加normal offset。第三看法线贴图和阴影贴图是否纠缠在一起。阴影深度pass里不要采样法线贴图主pass的阴影计算也尽量用几何法线而不是扰动后的法线。法线贴图骗的是人眼不是深度比较让它参与阴影深度计算只会带来额外的噪声和抖动。这三条检查完大部分阴影贴图问题都能定位到具体环节。尤其是深度pass和主pass的职责一定要分开这也是我后来看别人项目时最先看的地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑