ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity URP Shader阴影实现:从多Pass机制到实战调试

Unity URP Shader阴影实现:从多Pass机制到实战调试 1. 从“影子”说起为什么你的URP物体不投影也不接受阴影在Unity URP通用渲染管线里捣鼓Shader想让一个自定义的模型既能在地面上投下影子又能被其他物体的影子覆盖结果发现要么影子投不出来要么自己像个“透明人”一样不接受任何阴影——这几乎是每个从内置管线或标准管线转向URP的开发者都会遇到的经典问题。我刚开始接触URP Shader时也在这个坑里挣扎了好一阵子。问题往往不是出在灯光设置或者模型本身而是隐藏在Shader的Pass渲染通道和Tags标签里。简单来说在URP中“投影”Shadow Cast和“接受阴影”Shadow Receive是两个独立但又紧密关联的渲染过程。它们分别由不同的Pass来处理并且需要正确的LightMode标签来告诉URP渲染器“嘿我这个Pass是用来画投影深度信息的”或者“我这个Pass需要计算光照并应用阴影”。如果你只写了一个处理基础颜色的Pass那你的物体大概率既不会投影也看不到影子。网络上搜索“URP ShadowCast”或“ShadowRecieve”你会发现大量零散的代码片段和问题但缺少一个从“为什么”到“怎么做”的完整串联。今天我们就彻底拆解这个问题手把手构建一个同时支持投影和接受阴影的URP Unlit Shader从简单的开始原理更清晰并深入到URP的渲染流程中理解每一个设置背后的逻辑。你会发现一旦理清了管线的工作机制修复这些问题就是水到渠成的事。2. URP阴影机制的核心多Pass协作与LightMode标签要解决问题必须先理解URP是如何管理阴影的。这与旧版内置管线有显著不同。URP采用了一种更模块化、更高效的渲染方式它将渲染过程分解为多个可配置的“渲染器特性”Renderer Features和固定的“渲染通道”。对于阴影关键就在于两个核心的LightMode标签ShadowCaster和UniversalForward。2.1 ShadowCaster Pass投影的“模具”当一个物体需要向其他物体投射阴影时URP需要知道这个物体从灯光视角看过去的“形状”和“深度”。这个过程就是渲染阴影贴图Shadow Map。URP会寻找当前Shader中LightMode为ShadowCaster的Pass并用这个Pass来渲染物体到阴影贴图中。这个Pass渲染的内容通常不是物体的颜色而是其深度值。因此它的顶点着色器需要将顶点变换到灯光裁剪空间片元着色器通常只需要输出深度或者一个固定的颜色因为深度信息由渲染目标决定。如果你的Shader里根本没有ShadowCasterPass那么URP就找不到绘制该物体阴影深度信息的方法这个物体自然就无法产生投影。这是导致“不投影”最常见的原因。2.2 UniversalForward Pass 阴影接收光照与阴影的融合物体接收阴影的过程发生在主光照渲染阶段。在URP中负责处理基础颜色、光照和阴影的Pass其LightMode通常被设置为UniversalForward对于前向渲染或UniversalForwardOnly。在这个Pass里Shader需要能够访问到场景的阴影贴图并通过一系列计算通常是采样阴影贴图并进行比较来判断当前片元是否处于阴影中。如果处于阴影中则减弱或修改其光照贡献。URP通过内置的变量和函数如_MainLightShadowParams,SampleShadowmap为UniversalForwardPass提供了这些数据。但是仅仅有UniversalForwardPass还不够。为了让这个Pass能正确执行阴影接收计算我们必须做两件事包含正确的HLSL代码文件使用#include指令引入URP的核心光照和阴影库例如Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl。在片元着色器中明确进行阴影采样计算我们需要调用URP提供的函数来获取阴影衰减因子Shadow Attenuation并将其应用到光照计算中。如果一个Shader有UniversalForwardPass但没有进行上述阴影计算那么它可能会被正常照亮但会完全忽略其他物体投来的影子导致“不接受阴影”。2.3 多Pass的协作流程一个完整的、支持阴影的URP Shader其渲染流程可以简化为深度和阴影贴图渲染阶段URP调用Shader的ShadowCasterPass将物体渲染到阴影贴图中。主摄像机渲染阶段URP调用Shader的UniversalForwardPass。 a. 顶点着色器处理顶点变换。 b. 片元着色器计算基础颜色。 c.关键步骤采样阴影贴图计算当前片元的阴影衰减。 d. 结合光照颜色和阴影衰减输出最终颜色。这两个Pass是顺序执行的但属于不同的渲染批次。理解这个分离的流程是调试所有阴影相关问题的基石。3. 实战构建支持投影与接收阴影的URP Unlit Shader理论说得再多不如一行代码。我们从一个最简单的、不支持任何阴影的URP Unlit Shader开始逐步将其改造为完全支持阴影的版本。这里选择Unlit Shader是因为它不涉及复杂的光照模型让我们可以更专注于阴影机制本身。3.1 起点一个“孤独”的Unlit Shader这是初始状态它只有一个UniversalForwardPass只能显示纯色既不能投影也不接受阴影。Shader Custom/ShadowExample_Initial { Properties { _BaseColor (Base Color, Color) (1, 1, 1, 1) } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Name ForwardLit // 这个命名只是便于识别不是必须的 Tags { LightModeUniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionHCS : SV_POSITION; }; CBUFFER_START(UnityPerMaterial) half4 _BaseColor; CBUFFER_END Varyings vert (Attributes IN) { Varyings OUT; VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; return OUT; } half4 frag (Varyings IN) : SV_Target { return _BaseColor; } ENDHLSL } } }将这个Shader赋给一个物体它会显示为_BaseColor定义的颜色但在灯光下没有影子其他物体的影子也会穿透它。因为缺少ShadowCasterPass且UniversalForwardPass中没有阴影计算。3.2 第一步添加ShadowCaster Pass实现投影现在我们添加关键的ShadowCasterPass。URP实际上为我们准备了一个现成的、高效的ShadowCasterPass我们可以直接通过UsePass指令引用它这是最推荐且不易出错的方式。Shader Custom/ShadowExample_WithCast { Properties { _BaseColor (Base Color, Color) (1, 1, 1, 1) } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } // Pass 0: 原有的前向渲染Pass Pass { Name ForwardLit Tags { LightModeUniversalForward } ... // 和上面一样的HLSL代码 } // Pass 1: 阴影投射Pass // 关键使用UsePass引用URP内置的ShadowCaster Pass // “Universal Render Pipeline/Lit/ShadowCaster” 是这个内置Pass的路径 UsePass Universal Render Pipeline/Lit/ShadowCaster } }注意UsePass指令后面的路径是大小写敏感的必须完全匹配。“Universal Render Pipeline/Lit/ShadowCaster”是URP Lit Shader中对应Pass的完整名称。通过这种方式我们无需自己编写复杂的阴影深度计算直接复用经过充分优化的官方实现。完成这一步后你的物体应该已经可以在地面或其他物体上投下阴影了。但如果你观察这个物体本身它仍然不会显示其他物体投射在它身上的影子。3.3 第二步修改Forward Pass以接收阴影接下来我们要让物体能够“感受”到阴影。这需要在UniversalForwardPass的片元着色器中加入阴影采样逻辑。我们需要引入光照库并获取主光源的阴影数据。修改后的UniversalForwardPass关键部分如下Pass { Name ForwardLit Tags { LightModeUniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag // 核心补充1引入Core和Lighting库 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; // 核心补充2为了阴影计算通常需要法线信息 }; struct Varyings { float4 positionHCS : SV_POSITION; float3 positionWS : TEXCOORD0; // 核心补充3需要世界空间位置来采样阴影 float3 normalWS : TEXCOORD1; // 世界空间法线虽然Unlit可能不用但阴影计算可能需要 }; CBUFFER_START(UnityPerMaterial) half4 _BaseColor; CBUFFER_END // 声明用于阴影计算的关键纹理和采样器 TEXTURE2D(_MainLightShadowmapTexture); SAMPLER(sampler_MainLightShadowmapTexture); Varyings vert (Attributes IN) { Varyings OUT; VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); VertexNormalInputs normalInputs GetVertexNormalInputs(IN.normalOS); // 获取法线输入 OUT.positionHCS positionInputs.positionCS; OUT.positionWS positionInputs.positionWS; // 传递世界空间位置 OUT.normalWS normalInputs.normalWS; // 传递世界空间法线 return OUT; } half4 frag (Varyings IN) : SV_Target { // 准备阴影采样所需的输入数据 half4 shadowCoord TransformWorldToShadowCoord(IN.positionWS); // 采样主光源阴影 Light mainLight GetMainLight(shadowCoord); // 计算阴影衰减。1.0表示完全照亮0.0表示完全在阴影中。 half shadowAttenuation mainLight.shadowAttenuation; // 对于Unlit Shader我们可以简单地将阴影衰减作为颜色变暗的因子 // 更复杂的做法是将其与某种光照模型结合 half3 shadedColor _BaseColor.rgb * shadowAttenuation; return half4(shadedColor, _BaseColor.a); } ENDHLSL }代码解析与关键点#include Lighting.hlsl这是所有光照和阴影相关函数的大本营必须引入。输入结构体添加normalOS虽然我们是Unlit Shader但GetVertexNormalInputs和世界空间法线在某些阴影过滤或屏幕空间阴影计算中可能会被用到。为了兼容性和未来扩展最好加上。输出结构体添加positionWS和normalWSpositionWS世界空间位置是计算阴影坐标的必需品。normalWS在这里可能未直接使用但保持结构完整是良好实践。TransformWorldToShadowCoord函数这个神奇的函数将世界空间位置转换到用于采样_MainLightShadowmapTexture的阴影坐标空间。它自动处理了级联阴影映射Cascaded Shadow Maps的切换这是URP阴影系统的核心便利功能之一。GetMainLight(shadowCoord)这个函数不仅返回主光源的颜色和方向还自动完成了阴影贴图的采样和比较将结果存储在返回的Light结构体的shadowAttenuation成员中。这是我们获取阴影衰减最简洁的方式。应用阴影在片元着色器最后我们将shadowAttenuation范围通常在0到1之间与基础颜色相乘。值为1时颜色不变值小于1时颜色变暗从而模拟出阴影效果。完成以上两步后你的Shader就应该既能投射阴影也能接收阴影了。将Shader应用到一个材质球并赋给场景中的物体如Cube和Sphere调整一个方向光你应该能看到完整的阴影交互。4. 深度排查当阴影仍然不工作时的检查清单即使按照上面的步骤做了阴影可能还是有问题。别急阴影系统涉及多个环节我们需要系统性地排查。以下是我在项目中总结的检查清单按照从最常见到最隐蔽的顺序排列。4.1 物体与渲染器设置检查这是最基础的层面却经常被忽略。检查1物体的Renderer组件是否启用在Inspector面板中确保Mesh Renderer或Skinned Mesh Renderer组件的勾选框是选中的。被禁用的渲染器不会参与任何渲染包括阴影。检查2物体的Cast Shadows和Receive Shadows设置。在Renderer组件的“阴影”部分确保“投射阴影”和“接收阴影”选项被设置为正确的值通常是“On”或“默认”。虽然Shader支持但这里被关闭也会导致阴影失效。检查3材质球是否正确应用双击材质球确认其Shader确实是你修改后的Custom/ShadowExample_WithCast而不是其他类似名称的Shader。4.2 灯光与URP资产设置检查阴影是光与物体的交互光源的配置至关重要。检查4主光源的阴影类型和强度。选中场景中的Directional Light在Inspector面板中确保“阴影”不是“None”。对于方向光通常选择“Soft Shadows”或“Hard Shadows”。检查“阴影强度”是否大于0。强度为0意味着阴影完全透明。检查“阴影分辨率”是否设置得过低导致阴影锯齿严重或无法生成。检查5URP Asset渲染管线资产中的阴影设置。在Project窗口找到你的URP Asset文件通常名为UniversalRP-HighQuality等。选中它在Inspector中检查“阴影”设置。确保“最大距离”足够大能覆盖你的场景。物体如果超出这个距离将不会产生阴影。检查“级联数”和“每个级联的分辨率”。级联阴影映射CSM是处理远处阴影质量的关键技术配置不当会导致阴影在特定距离突然消失或质量骤降。4.3 Shader与渲染管线兼容性检查这是最核心的代码层面。检查6Shader的RenderPipeline标签是否正确在SubShader的Tags中必须包含RenderPipelineUniversalPipeline。这告诉Unity此Shader专为URP设计。检查7Pass的LightMode标签是否拼写正确“UniversalForward”和“ShadowCaster”一个字母都不能错。常见的错误是写成“ForwardBase”内置管线标签或“Shadowcaster”大小写错误。检查8是否错误地使用了内置管线的函数或变量例如在URP中不应使用UNITY_MATRIX_MVP而应使用TransformObjectToHClip或通过GetVertexPositionInputs获取。混合使用两个管线的API会导致不可预测的行为。检查9深度写入ZWrite和深度测试ZTest状态。在ShadowCasterPass中深度写入通常是开启的ZWrite On深度测试为LEqual。如果你自定义了ShadowCasterPass请确保这些状态设置正确。使用UsePass引用内置Pass则无需担心此问题。检查10Alpha裁剪Alpha Clipping与阴影。如果你的Shader使用了透明度裁剪clip函数必须在ShadowCasterPass中也进行完全一致的裁剪计算。否则阴影的形状会与物体实际可见的形状不匹配。URP的Lit Shader通过#pragma shader_feature _ALPHATEST_ON等指令自动处理了这一点。自定义Shader需要手动同步。4.4 平台与图形API相关检查某些问题只在特定平台或图形API下出现。检查11图形API兼容性。一些旧的移动设备GPU可能不支持某些阴影特性如采样器比较。确保你的目标平台支持所需的Shader Model。URP通常会自动处理这些但在极端情况下可能需要回退到无阴影的Shader变体。检查12渲染纹理格式。阴影贴图是一种特殊的渲染纹理。在极少数情况下如果项目修改了URP Asset中阴影贴图的格式而设备不支持该格式会导致阴影失效。保持默认设置通常是最安全的。按照这个清单从上到下检查99%的阴影问题都能被定位和解决。我的经验是大部分问题都出在检查1、2、4、7这几项上。5. 进阶性能考量与自定义ShadowCaster Pass在掌握了基础之后我们可以关注一些更深入的话题比如性能优化和特殊需求。5.1 阴影性能优化要点阴影是实时渲染中主要的性能消耗源之一。在URP中优化阴影可以从以下几个层面入手控制分辨率与距离在URP Asset中降低阴影贴图的分辨率和最大距离是最直接有效的方法。平衡视觉质量和性能。使用级联阴影CSM对于大型开放世界CSM是必须的。合理设置级联数量和分割距离确保近处阴影高质量远处低质量避免“一刀切”的高消耗。物体层级剔除利用相机的远裁剪平面和URP的每物体阴影距离设置让远处的物体不投射阴影。Shader复杂度自定义的ShadowCasterPass应尽可能简单。避免在其中进行复杂的纹理采样或数学运算。它的唯一任务就是输出正确的深度。静态批处理与GPU Instancing确保Shader支持这些合批技术可以减少Draw Call对阴影渲染同样有益。为ShadowCasterPass也添加#pragma multi_compile_instancing指令。5.2 何时需要自定义ShadowCaster Pass绝大多数情况下使用UsePass引用内置的ShadowCasterPass是最佳选择。但在以下场景你可能需要自己编写顶点动画或曲面细分如果你的物体在顶点着色器或曲面细分着色器中有形变如飘动的旗帜、水波内置的Pass使用的是原始网格数据无法反映动画后的形状会导致阴影“穿帮”。此时需要自定义Pass在其中重复你的顶点动画逻辑确保投影形状与视觉形状一致。自定义深度值某些特殊效果可能需要修改写入阴影贴图的深度值例如实现“体积阴影”或某些非真实感渲染效果。Alpha Masked物体的精确阴影对于使用Alpha Test透明度测试的物体如镂空贴图的树叶、栅栏必须确保在ShadowCasterPass中也执行完全相同的Alpha Test裁剪否则阴影会是一个实心的方块。这通常需要自己编写Pass来同步裁剪逻辑。一个支持Alpha Test的自定义ShadowCasterPass示例框架如下Pass { Name ShadowCaster Tags{LightMode ShadowCaster} ZWrite On ZTest LEqual ColorMask 0 // 阴影Pass通常不写入颜色只写深度 Cull[_Cull] HLSLPROGRAM #pragma vertex ShadowPassVertex #pragma fragment ShadowPassFragment #pragma shader_feature _ALPHATEST_ON // 同步Alpha Test关键字 #pragma shader_feature _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A #include Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl #include Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl // 如果你有自定义的顶点变形需要在这里重写GetVertexPositionInputs等函数 // 否则直接使用ShadowCasterPass.hlsl中提供的Vert和Frag函数即可 ENDHLSL }关键在于通过#include引入了处理Alpha Test等复杂情况的ShadowCasterPass.hlsl辅助文件而不是从头开始写。6. 常见陷阱与疑难杂症解析即使理解了原理实际开发中还是会遇到一些诡异的“坑”。这里分享几个我踩过并且有代表性的问题。6.1 半透明物体Alpha Blend与阴影这是一个根本性的限制标准的阴影贴图技术无法正确处理半透明Alpha Blend物体的投影。因为阴影贴图存储的是单个深度值而半透明物体背后的物体也可能被看到无法用一个深度值简单表示。现象一个使用Blend SrcAlpha OneMinusSrcAlpha的透明材质即使有ShadowCasterPass其投影也通常是黑色、不透明的实心块看起来很不自然。解决方案接受限制对于大多数游戏简单地将重要透明物体如玻璃、粒子设置为不投射阴影是最实用的选择。使用屏幕空间阴影Screen Space ShadowsURP提供了屏幕空间阴影技术作为补充它对透明物体的处理稍好但仍有局限且消耗较高。特殊Shader处理对于树叶等物体使用Alpha Testclip代替Alpha Blend。Alpha Test会产生硬边缘但至少阴影形状是正确的镂空形状。这就是为什么很多游戏中的植物使用Alpha Test材质的原因。6.2 双面渲染Cull Off物体的阴影对于需要双面渲染的物体如纸片、树叶阴影投射也可能出问题。现象默认情况下ShadowCasterPass会进行背面剔除Cull Back。这意味着从灯光方向看如果看到的是物体的背面这个面就不会被渲染到阴影贴图中导致投影缺失或残缺。解决方案在ShadowCasterPass中将剔除模式设置为Cull Off。Pass { Name ShadowCaster Tags{LightMode ShadowCaster} Cull Off // 改为双面渲染 ... // 其他设置 }注意这会使阴影计算量翻倍需谨慎使用。通常只对必须双面显示的薄物体使用。6.3 阴影“粉刺”Shadow Acne与深度偏移这是阴影渲染的经典问题由于深度贴图的分辨率有限和浮点数精度问题物体表面可能会错误地认为自己被自己的阴影所遮挡产生难看的条纹状自阴影即“粉刺”。现象物体表面出现细微的、交替的明暗条纹。解决方案在ShadowCasterPass中应用深度偏移Depth Bias。URP的内置Pass已经处理了这个问题。如果你自定义Pass可以在顶点着色器中手动添加偏移Varyings vert(Attributes input) { ... output.positionCS TransformWorldToHClip(positionWS); // 在裁剪空间下沿法线方向或灯光方向对深度进行微小偏移 #if UNITY_REVERSED_Z output.positionCS.z - _DepthBias; #else output.positionCS.z _DepthBias; #endif ... }更简单的方法是直接使用ApplyShadowBias或GetShadowPositionHClip等URP内置函数它们封装了正确的偏移逻辑并考虑了不同平台的Z方向。6.4 Shader变体与阴影关键字缺失URP的Lit Shader使用了很多Shader变体通过#pragma multi_compile和#pragma shader_feature来管理不同的渲染状态如是否有法线贴图、是否启用细节贴图等。如果你的自定义Shader基于Lit Shader修改而来但遗漏了某些阴影相关的变体编译指令可能会导致在特定情况下阴影失效。排查方法在Unity编辑器的Frame Debugger中查看渲染阴影贴图的Draw Call所使用的具体Shader变体。如果变体列表中没有包含阴影所需的关键字如_MAIN_LIGHT_SHADOWS_SHADOWS_SOFT那么阴影就不会被启用。解决方案确保你的Shader中包含了必要的光照和阴影变体指令。最简单的方法是参考URP Lit Shader的写法在UniversalForwardPass的编译指令中加上#pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _SHADOWS_SOFT这些指令会为不同的阴影质量设置生成对应的Shader变体。7. 调试工具与技巧让问题无处遁形当阴影表现不符合预期时拥有好的调试工具能事半功倍。7.1 使用Frame Debugger逐帧分析Unity的Frame Debugger窗口 - 分析 - Frame Debugger是调试渲染问题的神器。打开Frame Debugger并开始录制。找到渲染阴影贴图的步骤通常名为“Render Shadows”或“Draw Call: ShadowCaster”。点击对应的步骤在Scene视图和Game视图中你可以看到哪些物体被绘制到了阴影贴图中以及它们绘制的结果。如果某个物体缺失就能立刻锁定问题。同样检查主摄像机渲染步骤中你的材质是否被正确绘制以及使用的Shader Pass是否正确。7.2 可视化阴影数据有时我们需要直接看到阴影贴图或阴影衰减因子的值以判断计算是否正确。在Shader中调试输出可以临时修改片元着色器将shadowAttenuation直接作为颜色输出return half4(shadowAttenuation.xxx, 1.0)。这样完全照亮的地方是白色1.0完全阴影的地方是黑色0.0过渡区域是灰色。这能直观地验证阴影采样是否成功。使用RenderDoc等图形调试器这是更高级的工具可以捕获一帧完整的GPU调用和渲染纹理数据。你可以直接查看生成的阴影贴图纹理检查其内容是否正确。这对于诊断深度偏差、分辨率不足等底层问题非常有效。7.3 简化场景与材质进行隔离测试当问题复杂时采用“控制变量法”。创建一个全新的、最简单的场景只有一个平面作为地面一个立方体作为投射阴影的物体一个方向光。使用一个绝对标准的、未经修改的URP Lit材质赋给立方体测试阴影是否正常。如果正常说明问题出在你的自定义Shader或复杂场景的交互上。将你的自定义Shader逐步应用到测试场景中每做一步修改如添加Pass修改计算就测试一次从而精准定位引入问题的步骤。这个过程虽然繁琐但能帮你理清思路避免在复杂的项目环境中迷失方向。我自己在解决一个由多个自定义Shader和后期效果叠加导致的阴影闪烁问题时正是通过这种隔离测试最终发现是一个后处理效果意外修改了深度纹理格式所导致的。
返回列表