Unity Shader实战:让法线贴图细节在阴影中完美呈现
1. 项目概述阴影与法线的交响曲在Unity3D的世界里让一个模型看起来“真实”远不止是贴一张漂亮的图片那么简单。你有没有遇到过这种情况一个精心雕刻了高模细节、烘焙了精美法线贴图的角色或场景一旦走进阴影区域那些凹凸起伏的砖墙、磨损的皮革纹理、衣服的褶皱瞬间就“平”了变成了一块毫无细节的暗色平面。这种视觉上的割裂感是很多追求画面品质的项目都会遇到的痛点。今天要聊的就是如何解决这个痛点——在Shader中让模型的阴影部分也能正确、甚至更富艺术感地呈现出法线贴图带来的细节。简单来说这是一个关于“光影欺骗学”的课题。标准的光照模型无论是兰伯特还是冯氏计算阴影时通常只考虑顶点法线或一个非常简化的光照信息这导致了阴影内部细节的丢失。我们的目标是通过自定义Shader在阴影计算中“注入”法线贴图的信息让阴影区域的明暗变化、轮廓起伏也能跟随表面细节而变化从而极大地提升模型的视觉深度和整体沉浸感。无论你是正在开发写实风格的3A级手游还是风格化的独立游戏处理好阴影部分的法线效果都是让画面质感脱颖而出的关键一步。接下来我会从一个实战者的角度拆解这里面的核心原理、实现步骤并分享那些只有踩过坑才知道的优化技巧。2. 核心原理阴影为何会“吃掉”法线细节要解决问题首先得明白问题是怎么来的。在Unity的标准渲染管线Built-in或通用渲染管线URP中阴影的生成和渲染是相对独立的过程。理解这个流程是进行任何高级阴影处理的前提。2.1 标准阴影渲染流程简析当一束光平行光、点光源等照射场景时Unity会从光源的视角渲染一张深度图这张图记录了从光源位置看过去离光源最近的物体的深度值这就是阴影贴图。之后在摄像机渲染物体时会将该物体上的每个片段Fragment可以粗略理解为像素转换到光源的视角空间计算其深度值并与阴影贴图中存储的深度值进行比较。如果该片段的深度值大于阴影贴图中的值意味着它被其他物体挡住了则该片段处于阴影中。关键在于这个“比较”阶段。在标准的阴影计算函数如SHADOW_ATTENUATION或UnitySampleShadowmap中输出通常是一个简单的衰减因子——1.0表示完全受光0.0表示完全在阴影中或者在软阴影的情况下是0到1之间的渐变值。这个计算过程完全独立于物体表面的法线贴图。它只关心“位置是否被遮挡”而不关心“被遮挡的这个表面长什么样”。2.2 法线贴图在光照中的作用与局限法线贴图是一张RGB纹理其每个像素的RGB值对应一个法线向量的XYZ分量通常映射到[-1, 1]范围。在片元着色器中我们采样法线贴图将得到的向量从切线空间转换到世界空间或其他统一的空间如观察空间然后用这个细节法线去参与光照计算。经典的光照计算漫反射部分遵循兰伯特余弦定律漫反射强度 max(0, dot(N, L))。其中N是表面法线L是光源方向向量。当使用法线贴图后N变得丰富多变因此即使在一个平坦的几何面上也能计算出丰富的明暗变化模拟出凹凸感。然而阴影因子是在这个光照计算之后或与之并行进行乘法的。一个典型的简化着色器代码逻辑是这样的// 计算基础颜色和法线 float4 albedo tex2D(_MainTex, uv); float3 normal UnpackNormal(tex2D(_BumpMap, uv)); normal normalize(mul(normal, tangentToWorld)); // 转换到世界空间 // 计算漫反射光照 float diff max(0, dot(normal, _WorldSpaceLightPos0.xyz)); float3 diffuse _LightColor0.rgb * albedo.rgb * diff; // 计算阴影 float shadow SHADOW_ATTENUATION(i); // 这里与法线无关 // 最终颜色 漫反射颜色 * 阴影 环境光 float3 finalColor diffuse * shadow UNITY_LIGHTMODEL_AMBIENT.xyz;可以看到shadow是一个标量它平等地削弱了所有基于normal计算出的漫反射光。无论你的表面法线是朝左还是朝右只要它在阴影里就会被乘以一个很小的值如0.1结果就是一片近乎均匀的暗色法线贴图贡献的细节差异被极大地压缩甚至抹平了。2.3 我们的目标将法线信息融入阴影计算理解了上述局限我们的思路就清晰了不能简单地将阴影作为一个全局乘法因子而需要让它与法线进行某种形式的“互动”。这并不是说要修改Unity底层的阴影贴图生成算法那是极其复杂且不推荐的而是在我们自己的片元着色器里在应用阴影因子时采用更聪明的方法。核心思想有两种主流方向基于法线的阴影淡化不让阴影区域完全变黑而是根据法线信息让阴影内部的明暗也产生梯度。例如在阴影中让那些“背对”阴影光源即法线与光源方向点积为负的区域更暗而“侧对”的区域稍亮一些。屏幕空间阴影与法线结合在延迟渲染或一些自定义的前向渲染扩展中可以在屏幕空间对阴影和法线信息进行后处理实现更复杂的交互如让阴影沿着法线方向产生“偏移”或“模糊”。本文将重点探讨第一种即在前向渲染着色器中实现基于法线贴图的阴影增强这是最实用、性能可控且效果显著的方法。3. 实现方案构建支持法线细节的阴影着色器我们将从零开始构建一个在Built-in RP下可用的Surface Shader因其对光照和阴影集成度最高并解释关键步骤。URP的实现逻辑类似但语法和函数名有所不同。3.1 基础Shader结构搭建首先创建一个新的Surface Shader。Surface Shader是Unity对顶点/片元着色器的一种高级封装能帮我们处理很多光照和阴影的底层细节。Shader Custom/NormalShadowDetail { Properties { _MainTex (Albedo (RGB), 2D) white {} _BumpMap (Normal Map, 2D) bump {} // “bump”是Unity内置的蓝色法线贴图占位 _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 _ShadowDetailStrength (Shadow Detail Strength, Range(0, 1)) 0.5 // 控制阴影内法线影响强度的参数 } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM // 表面着色器编译指令启用了阴影接收 #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; sampler2D _BumpMap; half _Glossiness; half _Metallic; float _ShadowDetailStrength; struct Input { float2 uv_MainTex; float2 uv_BumpMap; }; // 表面函数 void surf (Input IN, inout SurfaceOutputStandard o) { // 采样反照率贴图 fixed4 c tex2D (_MainTex, IN.uv_MainTex); o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; // 采样并解包法线贴图切线空间 o.Normal UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap)); } ENDCG } FallBack Diffuse }这是一个最基础的、带法线贴图的标准PBR着色器。#pragma surface surf Standard fullforwardshadows中的fullforwardshadows指令确保了该着色器能正确地在前向渲染路径下接收阴影。但目前阴影部分还没有任何特殊处理。3.2 自定义光照模型介入阴影计算Surface Shader默认使用Standard光照模型。要修改阴影行为我们需要创建自定义的光照模型。我们将复制Standard模型的核心并加入自己的逻辑。首先需要包含必要的Unity CG库文件并声明自定义的光照函数。我们创建一个名为LightingNormalShadow的函数。// 在CGPROGRAM块顶部添加包含文件 #include UnityPBSLighting.cginc #include AutoLight.cginc // 为了使用阴影宏 // ... 之前的结构体和surf函数保持不变 ... // 自定义光照函数 // 这是一个简化版只处理了前向渲染基础通道的漫反射部分用于说明原理 // 实际完整的PBR光照非常复杂这里做概念性演示 inline float4 LightingNormalShadow(SurfaceOutputStandard s, float3 viewDir, UnityGI gi) { // 1. 首先进行标准的PBR光照计算简化版 float3 lightColor gi.light.color; float3 lightDir gi.light.dir; float atten gi.light.ndotl; // 注意这里的atten包含了距离衰减但不直接是阴影 // 计算法线与光源的点积兰伯特 float ndotl dot(s.Normal, lightDir); // 简单的漫反射 float3 diffuse lightColor * s.Albedo * max(0, ndotl); // 2. **关键步骤获取真实的阴影衰减** // 在Surface Shader中阴影信息通常通过UNITY_LIGHT_ATTENUATION宏获取到atten变量中。 // 但为了清晰演示我们假设有一个外部传入的阴影值。在实际修改中需要更深入地介入光照函数。 // 这里我们先跳过宏用一个概念性变量。 // 实际上我们需要重写更底层的SurfaceShaderStandard光照函数这超出了简单示例的范围。 // 一个更可行的方案是使用片段着色器或者在Surface Shader中使用finalcolor修饰符进行后处理。 // 3. 概念性算法基于法线修改阴影强度 // 假设我们有一个基础的阴影系数 baseShadow (0在阴影1在光中)。 // 我们想让阴影区域baseShadow小的法线影响更明显。 // 思路在阴影区减弱“光方向”对最终亮度的影响增强“法线自身变化”的影响。 // 一种混合方式finalDiffuse lerp(diffuseBasedOnNormal, diffuseBasedOnLight, baseShadow); // 在阴影中(baseShadow~0)使用更多基于法线自身比如与一个固定“假”光源计算的颜色。 }上面的代码卡在了一个关键点在Surface Shader的标准光照模型框架内我们很难直接在一个地方同时拿到法线贴图计算后的法线、原始阴影衰减和光源方向并进行混合。UNITY_LIGHT_ATTENUATION宏提供的衰减已经是一个最终乘积。因此要实现精细控制我们往往需要使用顶点片段着色器Vertex-Fragment Shader放弃Surface Shader的便利获得完全的控制权。在片元着色器中我们可以自己采样阴影贴图通过UnitySampleShadowmap拿到原始的阴影比较结果然后再与基于法线的光照计算进行自定义混合。利用finalcolor修饰符在Surface Shader中可以添加#pragma surface surf Standard finalcolor:myFinalColor并定义myFinalColor函数。这个函数在所有标准光照计算完成后被调用我们可以在这里对最终输出颜色进行后处理例如根据法线对阴影区域进行提亮或压暗。但这仍然是对最终结果的操作而非在光照计算过程中介入。鉴于篇幅和清晰度我们转向一个更直观、在片段着色器中实现的简化实用方案。这个方案虽然物理上不完全精确但能有效提升阴影区的视觉细节且性能良好。3.3 实战代码片段着色器中的法线阴影增强我们创建一个不依赖Surface Shader的顶点/片元着色器。核心思路是在片元着色器中我们分别计算两套光照。一套是真实光照使用法线贴图、真实光源方向并乘以标准的阴影衰减。另一套是“假想光照”使用法线贴图但光源方向使用一个固定的、柔和的“环境光源”方向比如正上方或摄像机方向的一个偏置并且不受真实阴影影响。然后根据真实的阴影衰减值将这两套结果进行线性混合。在完全受光处使用100%的真实光照在完全阴影处使用100%的“假想光照”在软阴影的过渡区则进行平滑混合。这样即使在阴影内部法线贴图也能通过“假想光照”产生明暗变化。Shader Custom/NormalShadowDetail_VF { Properties { _MainTex (Albedo, 2D) white {} _BumpMap (Normal Map, 2D) bump {} _ShadowBlend (Shadow Blend, Range(0,1)) 0.3 // 混合系数控制假光照的强度 _FakeLightDir (Fake Light Direction, Vector) (0.3, 1, 0.5, 0) // 假光源方向世界空间 } SubShader { Tags { RenderTypeOpaque LightModeForwardBase} // 前向渲染基础通道 LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase // 编译阴影相关变体 #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc // 必须包含用于阴影宏 struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; // 切线信息用于法线贴图 float2 texcoord : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; float3 worldPos : TEXCOORD1; float3 tspace0 : TEXCOORD2; // 切线空间转换矩阵 float3 tspace1 : TEXCOORD3; float3 tspace2 : TEXCOORD4; SHADOW_COORDS(5) // 声明阴影坐标占用TEXCOORD5 }; sampler2D _MainTex; float4 _MainTex_ST; sampler2D _BumpMap; float _ShadowBlend; float3 _FakeLightDir; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.texcoord, _MainTex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 构建从切线空间到世界空间的转换矩阵 float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldTangent UnityObjectToWorldDir(v.tangent.xyz); float3 worldBitangent cross(worldNormal, worldTangent) * v.tangent.w; o.tspace0 float3(worldTangent.x, worldBitangent.x, worldNormal.x); o.tspace1 float3(worldTangent.y, worldBitangent.y, worldNormal.y); o.tspace2 float3(worldTangent.z, worldBitangent.z, worldNormal.z); TRANSFER_SHADOW(o); // 计算并传递阴影坐标 return o; } fixed4 frag (v2f i) : SV_Target { // 1. 采样纹理 fixed4 albedo tex2D(_MainTex, i.uv); float3 normalTangent UnpackNormal(tex2D(_BumpMap, i.uv)); // 2. 将法线从切线空间转换到世界空间 float3 worldNormal; worldNormal.x dot(i.tspace0, normalTangent); worldNormal.y dot(i.tspace1, normalTangent); worldNormal.z dot(i.tspace2, normalTangent); worldNormal normalize(worldNormal); // 3. 计算真实光照带阴影 float3 realLightDir normalize(_WorldSpaceLightPos0.xyz); // 平行光方向 float ndotl_real max(0, dot(worldNormal, realLightDir)); float3 realDiffuse _LightColor0.rgb * albedo.rgb * ndotl_real; // 4. 应用真实阴影衰减 UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); // 这个宏会计算阴影和距离衰减 realDiffuse * atten; // 真实光照被阴影削弱 // 5. 计算“假想”光照不受阴影影响 float3 fakeLightDir normalize(_FakeLightDir); float ndotl_fake max(0, dot(worldNormal, fakeLightDir)); // 假光照的颜色可以稍微暗一些或者用环境光颜色 float3 fakeDiffuse unity_AmbientSky.rgb * albedo.rgb * ndotl_fake * 0.5; // 6. 混合两种光照结果 // 当atten小阴影深时使用更多fakeDiffuse // 当atten大受光好时使用更多realDiffuse float shadowFactor atten; // atten已经是[0,1]的阴影值 float3 finalDiffuse lerp(fakeDiffuse, realDiffuse, shadowFactor); // 7. 添加环境光并输出 float3 ambient unity_AmbientSky.rgb * albedo.rgb; float3 finalColor finalDiffuse ambient; return fixed4(finalColor, albedo.a); } ENDCG } } FallBack Standard // 使用标准着色器作为回退以确保阴影投射等功能正常 }注意这是一个高度简化的示例省略了高光反射Specular、完整的PBR计算、多光源处理等。但它清晰地展示了核心算法利用lerp函数根据标准阴影衰减值atten在基于法线的“真实阴影光照”和基于法线的“无阴影假光照”之间进行混合。_ShadowBlend参数可以用来进一步控制假光照的强度或混合曲线例如lerp(fakeDiffuse * _ShadowBlend, realDiffuse, shadowFactor)。3.4 参数调节与艺术化控制上面的着色器提供了两个关键的艺术控制参数_FakeLightDir假想光源的方向。这个方向决定了阴影内部“虚构”出的光照来自何方。通常可以设置为场景主光源的大致方向或者一个固定的、能产生好看侧光的方向如(0.5, 1, 0.2)。不同的方向会给阴影内部带来截然不同的“体积感”。_ShadowBlend在示例代码中它被用来控制假光照的强度。你可以扩展它比如用一个曲线纹理Curve或幂函数pow来重映射shadowFactor实现非线性的混合效果。例如float blendFactor pow(shadowFactor, _ShadowBlend);当_ShadowBlend大于1时阴影边缘过渡会更锐利小于1时过渡会更柔和阴影内部保留的假光照更多。4. 进阶技巧与性能优化实现基础效果后我们还需要考虑如何在项目中稳健、高效地使用它。4.1 结合屏幕空间环境光遮蔽SSAO法线阴影增强与SSAO是天作之合。SSAO通过计算像素周围几何体的遮挡情况来模拟环境光遮蔽让角落和缝隙更暗。我们的技术让阴影内部有了细节而SSAO则能强化这些细节的立体感。在URP或HDRP中通常只需在渲染设置中启用SSAO后处理我们的着色器就能自动受益。在Built-in RP中可能需要依赖图像效果Image Effect或自定义的后期处理栈。结合要点确保你的法线贴图是用于世界空间或视图空间的。很多SSAO算法需要视图空间法线作为输入。在我们的着色器中我们已经计算了worldNormal可以将其输出到自定义的渲染纹理如使用GrabPass或命令缓冲区供SSAO后处理使用或者直接使用Unity内置的CameraDepthNormalsTexture如果支持。4.2 性能考量与多平台适配额外的纹理采样和计算相比标准着色器我们多了一次法线贴图采样本来就有和一次额外的点积计算dot(worldNormal, fakeLightDir)。这在现代桌面GPU上开销几乎可以忽略但在低端移动设备上需要关注。如果性能吃紧可以考虑在移动平台使用更简单的、没有法线阴影增强的着色器变体使用#pragma multi_compile_fog和#pragma shader_feature来条件编译。降低假光照的计算精度例如使用半精度浮点数half。简化假光照模型比如不使用方向光而是用一个恒定的、基于法线Y分量的值来模拟顶部环境光。阴影质量设置我们的效果质量很大程度上依赖于原始的阴影质量atten的精度和软硬程度。确保项目的阴影距离、分辨率、级联Cascaded Shadows设置合理。低分辨率的阴影贴图会产生锯齿混合后可能会放大瑕疵。Forward vs Deferred Rendering我们的示例是基于前向渲染的。在延迟渲染路径下光照和阴影的计算方式不同。你可能需要编写一个自定义的延迟着色器Deferred Shader或者通过屏幕空间阴影遮罩Screen-space Shadow Mask结合法线GBuffer来实现类似效果这更为复杂但效率更高。4.3 常见问题排查与调试阴影边缘出现光晕或错误混合原因最常见的原因是atten的值在阴影边缘软阴影区域不是平滑过渡的或者_FakeLightDir设置得太强。fakeDiffuse的颜色或强度可能超过了realDiffuse在弱光处的值。排查在片元着色器中将finalColor直接输出为fixed4(shadowFactor, shadowFactor, shadowFactor, 1)可视化atten的分布。它应该是从阴影内部的0到完全受光处的1平滑过渡。检查软阴影设置。解决降低fakeDiffuse的强度乘以一个小于1的系数或者使用saturate函数确保颜色值不溢出。调整lerp的插值曲线。法线贴图在阴影中效果不明显原因_FakeLightDir方向可能与表面法线夹角过大导致ndotl_fake值普遍偏小。或者fakeDiffuse的基础亮度太低。排查将fakeDiffuse输出为颜色观察阴影区域是否真的有变化。解决调整_FakeLightDir为一个更“正面”照射模型的方向。适当提高假光照的亮度或对比度。可以尝试让假光照也带一点颜色如冷色调来模拟环境光反射增加艺术效果。移动设备上效果异常或性能差原因可能是精度问题或Shader变体未正确编译。排查确保在移动端使用了正确的精度修饰符如half,fixed。检查Unity编辑器的编译错误和警告。解决为移动端创建简化版Shader或者使用#ifdef SHADER_API_MOBILE ... #endif来包裹高开销计算。确保法线贴图使用了压缩格式如DXT5nm或BC5并启用了Mipmaps。5. 在URP/HDRP中的实现思路在可编程渲染管线URP/HDRP中着色器的编写范式完全不同但核心思想相通。URP提供了更模块化的光照和阴影处理方式。URP Lit Shader Graph如果你使用Shader Graph实现起来更直观。正常创建Lit Master节点连接Albedo、Normal等贴图。关键点在于处理Main Light的阴影。你可以通过Sample Screen Space Shadows节点获取阴影图。然后你需要复制一份光照计算流程一份连接真实的Main Light Direction和阴影图另一份使用一个自定义的Vector3作为假光源方向并且不连接阴影图。最后用一个Lerp节点以阴影图的采样结果或经过处理的阴影强度作为Alpha混合这两份光照计算结果再输出给Color端口。URP自定义Lit着色器代码你需要编写一个继承自Lit的Shader并重写其片元着色器或光照函数。URP的阴影数据通过Light结构体传递。你可以获取light.shadowAttenuation然后应用类似的混合逻辑。URP的代码结构更清晰但需要熟悉其库函数如GetMainLight()和MixRealtimeAndBakedShadows。无论管线如何理解“分离阴影计算与法线光照计算并通过自定义逻辑重新融合”这一核心思想是攻克此类渲染难题的关键。从简单的lerp混合开始逐步试验不同的假光照模型和混合曲线你就能为你的项目找到最能提升视觉品质的那把“钥匙”。记住最好的技术往往是那些让玩家感觉不到存在却让画面整体质感大幅提升的技术。