资讯中心

URP平面阴影实现:原理、Shader与CommandBuffer实战

📅 2026/8/6 6:53:02
URP平面阴影实现:原理、Shader与CommandBuffer实战
1. 项目概述为什么要在URP里折腾平面阴影做项目的时候尤其是那种俯视角、2.5D或者需要在地面、水面等平面上投射清晰阴影的场景Unity内置的阴影方案有时候会显得“太重”或者“不够精确”。比如一个角色站在地面上你希望他的影子就老老实实地贴在地面上不要因为模型复杂或者场景物件多而产生奇怪的锯齿、闪烁或者因为性能开销太大而不得不关掉阴影。这时候“平面阴影”这个老牌但极其有效的技术就派上用场了。平面阴影顾名思义就是在一个指定的平面上比如地面生成阴影。它的核心思想不是去模拟真实的光线追踪而是通过一种“投影”的数学变换把模型“拍扁”到目标平面上。在Unity的URP通用渲染管线里实现它意味着我们不再完全依赖管线内置的阴影贴图Shadow Map而是自己动手用Shader和一点点脚本在GPU上高效地计算出这些投影。这对于移动端性能优化、风格化渲染或者需要特定阴影表现如RTS游戏里单位的影子的场景来说是性价比极高的选择。最近看到不少朋友在搜“URP Shader 体积光”、“高亮”这些效果其实平面阴影和它们是同一个层面的“渲染技巧”。掌握了它你就掌握了自定义渲染效果的一把钥匙。今天我就结合自己踩过的坑和优化经验把URP下平面阴影从原理到实现的完整链条拆解清楚让你不仅能“抄作业”更能明白每一步背后的“所以然”。2. 核心原理拆解从数学“拍扁”到GPU绘制要自己造轮子先得知道轮子是怎么转的。平面阴影的原理本质上是一道经典的计算机图形学应用题如何将一个三维模型沿着平行光的方向投影到一个无限大的平面上。2.1 阴影的本质一个投影矩阵想象一下有一束平行光比如太阳光照向一个物体。物体背后有一个无限大的地面。阴影就是物体挡住光后在地面上形成的黑色轮廓。在数学上我们可以把这个过程描述为将物体上每一个顶点沿着光线方向的反方向因为影子是光被阻挡后形成的“缺失”区域移动直到它与平面相交。这个“移动直到相交”的过程可以用一个特殊的矩阵——平面投影矩阵——来一次性完成对所有顶点的变换。这个矩阵是核心中的核心。给定一个平面通常由平面法向量N和平面到原点的距离d来定义即N·P d 0和一个光线方向L我们可以推导出这个矩阵M。推导过程有点绕但结论很直观这个矩阵会把顶点V变换到V M * V。变换后的V的y坐标如果我们假设地面是XZ平面就是投影后的高度通常设为0或一个固定值而x和z坐标就是影子在地面上的位置。注意这里的光线方向L通常需要是归一化的向量并且是指向平面的方向即光照射的方向。在计算投影时我们实际使用的是-L因为顶点是沿着光线相反方向被“推”到平面上的。2.2 在URP中的实现位置Shader还是CommandBuffer理解了原理接下来要决定在URP的哪个环节插入我们的计算。主要有两个主流思路在Shader中实时计算为需要接收阴影的材质比如地面Shader编写一个Custom Lit Shader。在这个Shader的顶点或片元着色器中根据光源信息主要是平行光方向和模型空间位置实时计算投影坐标然后采样投影体的纹理或直接渲染一个阴影颜色。这种方法灵活每个像素都能精确计算但计算量稍大且需要修改地面材质。使用CommandBuffer绘制阴影层这是更高效、更通用的方法。我们通过脚本在相机渲染完不透明物体之后、渲染透明物体之前向URP的渲染管线插入一个自定义的绘制命令CommandBuffer。在这个命令中我们使用一个专门的“阴影绘制Shader”将需要投射阴影的物体如角色、建筑用我们推导出的投影矩阵再绘制一遍但这次绘制只输出到特定的渲染目标比如相机的帧缓冲并配合模板缓冲或深度测试来确保影子只画在地面之上。为什么我强烈推荐第二种CommandBuffer方法因为它解耦了。你的地面材质可以继续使用URP自带的Lit Shader保持所有光照和纹理特性不变。阴影是作为一个独立的“层”叠加上去的。这带来了巨大的好处管理方便一个脚本管理所有阴影投射体、性能可控可以指定哪些层级的物体投射阴影、效果稳定不会受地面材质复杂度的干扰。我们接下来的实现也将围绕这个方案展开。3. 完整实现方案脚本与Shader的协同作战理论到位开始动手。我们的目标是创建一个PlanarShadowRenderer脚本和一个PlanarShadow.shader文件。3.1 第一步创建平面投影矩阵首先我们需要一个C#工具函数来生成那个核心的投影矩阵。这个矩阵会将世界空间中的点投影到我们定义的平面上。using UnityEngine; public static class PlanarShadowUtils { // 根据平面法线距离和光线方向计算平面投影矩阵 // plane: Vector4 (nx, ny, nz, d) 其中 (nx, ny, nz)是法线d是平面方程常数项 // lightDir: 世界空间中的光线方向归一化指向平面即光照射的方向 public static Matrix4x4 GetShadowProjectionMatrix(Vector4 plane, Vector3 lightDir) { Matrix4x4 mat Matrix4x4.identity; // 平面法线 Vector3 n new Vector3(plane.x, plane.y, plane.z); // 平面常数 d float d plane.w; // 计算投影矩阵的各个分量 // 这是标准的平面投影矩阵推导公式M I - (lightDir ⊗ plane) / (n·lightDir) // 其中 ⊗ 表示外积outer product float dot Vector3.Dot(n, lightDir); // 避免除零如果光线与平面几乎平行则没有有效投影 if (Mathf.Abs(dot) 1e-6f) { return Matrix4x4.zero; // 或返回单位矩阵但阴影会失效 } float a n.x * lightDir.x; float b n.x * lightDir.y; float c n.x * lightDir.z; float e n.y * lightDir.x; float f n.y * lightDir.y; float g n.y * lightDir.z; float i n.z * lightDir.x; float j n.z * lightDir.y; float k n.z * lightDir.z; mat.m00 1 - a / dot; mat.m01 -b / dot; mat.m02 -c / dot; mat.m03 -d * lightDir.x / dot; mat.m10 -e / dot; mat.m11 1 - f / dot; mat.m12 -g / dot; mat.m13 -d * lightDir.y / dot; mat.m20 -i / dot; mat.m21 -j / dot; mat.m22 1 - k / dot; mat.m23 -d * lightDir.z / dot; mat.m30 0; mat.m31 0; mat.m32 0; mat.m33 1; return mat; } }关键参数解析plane 我们通常将地面定义为(0, 1, 0, 0)即法线朝上Y轴且经过原点。如果你的地面在y -1的位置那么plane就是(0, 1, 0, 1)因为0*x 1*y 0*z 1 0 y -1。lightDir这里最容易出错。这个方向必须是世界空间中光照射的方向。例如太阳在头顶正上方方向是(0, -1, 0)。很多实现错误地使用了光线指向物体的方向导致投影反向。3.2 第二步编写阴影绘制Shader这个Shader的唯一目的就是用投影矩阵绘制出纯色的影子形状。它非常简单通常是一个Unlit Shader。// PlanarShadow.shader Shader Hidden/PlanarShadow { Properties { _ShadowColor (Shadow Color, Color) (0, 0, 0, 0.5) } SubShader { Tags { RenderTypeOpaque QueueGeometry1 } // 关键使用Alpha混合让影子半透明叠加 Blend SrcAlpha OneMinusSrcAlpha ZWrite Off // 关闭深度写入我们不希望影子影响后续物体的深度测试 // 关键只绘制在比地面更远深度值更大的片段确保影子贴在地上 ZTest Greater // 可选使用模板测试来精确控制影子绘制区域避免穿帮到墙壁上 // Stencil { // Ref 1 // Comp Equal // Pass Keep // } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; // 从脚本传递过来的投影矩阵 float4x4 _WorldToShadowMatrix; fixed4 _ShadowColor; v2f vert (appdata v) { v2f o; // 将模型顶点从物体空间变换到世界空间 float4 worldPos mul(unity_ObjectToWorld, v.vertex); // 应用我们计算出的平面投影矩阵得到投影后的世界坐标 float4 shadowWorldPos mul(_WorldToShadowMatrix, worldPos); // 最后变换到齐次裁剪空间 o.pos mul(UNITY_MATRIX_VP, shadowWorldPos); return o; } fixed4 frag (v2f i) : SV_Target { // 直接输出阴影颜色可以是一个带透明度的黑色或深色 return _ShadowColor; } ENDCG } } }Shader关键点解析ZWrite Off与ZTest Greater 这是实现“影子只在地面上”的精髓。我们先正常渲染场景地面的深度值被写入深度缓冲。然后绘制阴影时设置ZTest Greater意味着只有片段的深度值大于深度缓冲中现有值即在地面“后面”时才会通过测试并被绘制。同时关闭深度写入避免阴影自身的绘制影响后续物体的深度判断。混合模式Blend SrcAlpha OneMinusSrcAlpha 这是最常用的Alpha混合让阴影颜色与地面颜色进行混合实现半透明效果。顶点变换流程Object - World - (Projected World) - Clip Space。注意我们是在世界空间应用自定义的投影矩阵。3.3 第三步创建Renderer脚本驱动渲染这是连接所有部分的大脑。脚本需要获取场景主光计算矩阵设置Shader参数并安排CommandBuffer的执行时机。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [RequireComponent(typeof(Camera))] public class PlanarShadowRenderer : MonoBehaviour { [Header(Shadow Plane)] public Vector3 planeNormal Vector3.up; // 平面法线通常朝上 public float planeHeight 0f; // 平面高度世界坐标Y值 [Header(Shadow Settings)] public Color shadowColor new Color(0, 0, 0, 0.5f); public LayerMask shadowCasterLayer -1; // 指定哪些层级的物体会投射阴影 [Header(Performance)] public bool updatePerFrame true; // 每帧更新矩阵适用于动态光源 private Camera _camera; private CommandBuffer _commandBuffer; private Material _shadowMaterial; // 用于存储投影矩阵的Shader属性ID避免字符串查找开销 private static readonly int WorldToShadowMatrixID Shader.PropertyToID(_WorldToShadowMatrix); private static readonly int ShadowColorID Shader.PropertyToID(_ShadowColor); // 计算平面表示 Vector4(nx, ny, nz, d) private Vector4 ShadowPlane new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, -planeHeight); void OnEnable() { _camera GetComponentCamera(); if (_camera null) return; // 创建CommandBuffer _commandBuffer new CommandBuffer { name Planar Shadows }; // 创建材质 Shader shader Shader.Find(Hidden/PlanarShadow); if (shader null) { Debug.LogError(PlanarShadow shader not found. Please assign the correct shader.); return; } _shadowMaterial new Material(shader); // 将CommandBuffer插入到URP渲染管线中 // 通常在渲染完不透明物体后天空盒之前插入 var renderPipeline RenderPipelineManager.currentPipeline as UniversalRenderPipeline; if (renderPipeline ! null) { // 更现代和可靠的方式订阅渲染管线的事件 RenderPipelineManager.beginCameraRendering OnBeginCameraRendering; } } void OnDisable() { Cleanup(); } void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera) { if (camera ! _camera) return; // 每帧更新光源方向和投影矩阵 UpdateShadowMatrix(); // 配置CommandBuffer _commandBuffer.Clear(); _commandBuffer.SetGlobalColor(ShadowColorID, shadowColor); // 查找所有需要投射阴影的渲染器 // 注意这里简单遍历所有对象对于大型场景应考虑空间划分如四叉树或静态批处理优化 var shadowCasters FindObjectsOfTypeRenderer(); foreach (var renderer in shadowCasters) { if (((1 renderer.gameObject.layer) shadowCasterLayer) 0) continue; if (renderer.isVisible false) continue; // 仅渲染视锥体内的物体 // 为每个渲染器设置矩阵并绘制 // 矩阵需要从世界空间-投影世界空间 // 在Shader中我们做了worldPos - mul(_WorldToShadowMatrix, worldPos) // 所以这里传递给Shader的矩阵应该是 _WorldToShadowMatrix // 但注意Shader中第一个mul是 unity_ObjectToWorld将顶点转到世界空间 // 因此我们不需要在这里拼接矩阵Shader里已经做了。 // 我们只需要将投影矩阵传递给Shader。 _commandBuffer.SetGlobalMatrix(WorldToShadowMatrixID, _shadowProjectionMatrix); _commandBuffer.DrawRenderer(renderer, _shadowMaterial, 0, 0); // submesh index 0, pass 0 } // 将CommandBuffer加入到上下文中执行 context.ExecuteCommandBuffer(_commandBuffer); } private Matrix4x4 _shadowProjectionMatrix; private void UpdateShadowMatrix() { // 获取主平行光方向 Light mainLight RenderSettings.sun; Vector3 lightDir Vector3.down; // 默认方向 if (mainLight ! null mainLight.type LightType.Directional) { lightDir -mainLight.transform.forward; // 光源的forward指向光照方向取反得到指向平面的向量 } // 计算投影矩阵 _shadowProjectionMatrix PlanarShadowUtils.GetShadowProjectionMatrix(ShadowPlane, lightDir); // 将矩阵传递给材质 if (_shadowMaterial ! null) { _shadowMaterial.SetMatrix(WorldToShadowMatrixID, _shadowProjectionMatrix); _shadowMaterial.SetColor(ShadowColorID, shadowColor); } } void Cleanup() { RenderPipelineManager.beginCameraRendering - OnBeginCameraRendering; if (_commandBuffer ! null) { _commandBuffer.Release(); _commandBuffer null; } if (_shadowMaterial ! null) { DestroyImmediate(_shadowMaterial); _shadowMaterial null; } } }脚本实现要点事件订阅 使用RenderPipelineManager.beginCameraRendering事件来插入CommandBuffer这是URP推荐的方式能确保在正确的渲染阶段执行。性能考虑 脚本中通过FindObjectsOfType查找渲染器这在每帧执行且物体很多时是性能瓶颈。在实际项目中你必须优化这里。常见做法是让需要投射阴影的物体主动注册到一个静态列表ListRenderer中。或者将这些物体放在特定的Layer然后使用Physics.OverlapSphere或自定义管理脚本来动态管理可见的投射体。光源获取 我们默认使用RenderSettings.sun作为方向光。对于更复杂的多光源或动态光源场景你需要从URP的Lighting数据中获取主光源方向这涉及到UniversalRenderPipeline.asset的设置和ShaderGraph中的Main Light节点对应的数据获取。4. 高级优化与问题排查基础功能跑通后我们会遇到各种实际问题和优化需求。下面是我在实践中总结的几个关键点和解决方案。4.1 阴影“悬浮”或“穿透”问题这是最常见的问题。现象是影子没有紧贴地面要么浮在空中一点要么穿到地面下面去了。原因与解决方案深度测试精度问题Z-Fighting 影子网格被投影后其深度值可能与地面网格的深度值无限接近由于深度缓冲的精度限制导致测试结果不稳定。解决方案是给投影顶点一个微小的偏移Bias。在Shader中修正 在顶点着色器将投影坐标变换到裁剪空间后手动减小其深度值clipPos.z - _ShadowBias * clipPos.w。_ShadowBias是一个很小的值如0.001。在矩阵中修正 在计算投影矩阵时将平面稍微向光线方向的反方向即“上”方偏移一点点。例如将planeHeight增加一个微小值bias。平面定义不匹配 你的Shader中用于深度测试的地面和脚本中计算投影矩阵所使用的平面必须是同一个平面。确保你的地面模型的世界坐标Y值与脚本中planeHeight参数一致。如果地面有起伏非平面则平面阴影技术本身就不适用需要考虑其他方案如投影贴图Projector或屏幕空间阴影。4.2 性能优化实战平面阴影虽然比完整的Shadow Map开销小但无节制的绘制也会造成性能问题。剔除Culling优化视锥体剔除 脚本中已经通过renderer.isVisible进行了初步判断。确保你的相机视锥体设置合理。距离剔除 在UpdateShadowMatrix或绘制循环中计算投射体与相机的距离超过一定阈值就跳过。这对于开放世界游戏至关重要。屏幕空间占比剔除 计算物体包围盒在屏幕上的投影面积如果面积太小比如小于10像素其阴影贡献度极低可以跳过绘制。绘制调用Draw Call合并目前的实现是为每个Renderer调用一次DrawRenderer这会产生多个绘制调用。如果多个投射体使用相同的材质我们的_shadowMaterial并且投影矩阵相同对于静态光源和地面矩阵每帧是相同的我们可以尝试合并。静态批处理 对于永远不会移动的阴影投射体如静态建筑可以提前将它们合并成一个大的网格用一个绘制调用完成。GPU Instancing 修改Shader使其支持GPU Instancing。然后在CommandBuffer中使用DrawMeshInstanced或DrawRendererInstanced来批量绘制。这需要将每个实例的变换矩阵物体到世界矩阵通过数组传递给Shader。注意由于我们应用了自定义的世界空间投影矩阵实例化时需要将unity_ObjectToWorld矩阵数组与投影矩阵在Shader中分别处理逻辑会复杂一些。精度与质量权衡对于远处或快速移动的物体可以降低阴影渲染的精度。例如使用物体的简化版本LOD低模来投射阴影或者每两帧更新一次阴影位置。4.3 与URP内置渲染的兼容性我们的平面阴影是“后加”的效果需要和URP自身的渲染流程和谐共处。渲染顺序 我们选择在beginCameraRendering事件中在URP渲染完不透明物体后插入CommandBuffer。这个时机很重要必须在天空盒之前透明物体之后。因为我们的阴影需要基于不透明物体地面的深度进行测试并且本身是半透明的需要在透明物体之前渲染。多相机处理 如果场景中有多个相机如UI相机、小地图相机我们的脚本默认只作用于挂载的相机。你需要判断OnBeginCameraRendering中的camera参数决定是否为该相机渲染阴影。通常只为主游戏相机渲染。Shader变体与SRP Batcher URP的SRP Batcher可以提升合批效率但要求Shader符合其规范。我们的Hidden/PlanarShadowShader是一个简单的Unlit Shader没有使用URP的CBUFFER因此不会被SRP Batcher优化。这通常可以接受因为阴影绘制调用本身是我们手动控制的。如果你希望兼容需要按照URP Shader Library的格式来编写但这会增加复杂性。4.4 常见问题速查表问题现象可能原因排查步骤与解决方案完全没有阴影1. Shader未正确编译或找不到。2. CommandBuffer未正确插入渲染管线。3. 光源方向计算错误导致投影矩阵无效如除零。1. 检查Console是否有Shader错误。在脚本中Debug.Log_shadowMaterial是否创建成功。2. 在Frame Debugger中查看渲染事件确认你的CommandBuffer是否被记录和执行。3. Debug.Log光源方向lightDir和点积dot值。确保光线不与平面平行。阴影位置错误1. 平面高度 (planeHeight) 设置错误。2. 投影矩阵计算错误尤其是光线方向符号。1. 在Scene视图中可视化你的平面如画一条线。确保与地面模型对齐。2. 记住lightDir是光照射的方向从光源指向平面。对于头顶的平行光方向是(0, -1, 0)。可以尝试取反方向看看。阴影闪烁 (Z-Fighting)深度测试精度冲突。在Shader或矩阵计算中引入深度偏移 (_ShadowBias)。从0.001开始微调。阴影绘制在错误物体上1. LayerMask设置错误。2. 深度测试 (ZTest) 条件不对。1. 检查shadowCasterLayer是否包含了目标物体所在的层。2. 确认地面是否先于阴影被渲染且写入了深度。在Frame Debugger中查看渲染顺序。确保阴影Shader的ZTest是Greater。性能低下1. 每帧遍历所有FindObjectsOfType。2. 阴影投射体过多或网格过于复杂。1. 实现一个注册管理器让物体动态加入/离开阴影投射列表。2. 为复杂物体设置阴影LOD低模或增加距离剔除。5. 效果扩展与进阶思路实现了基础平面阴影后你可以在此基础上玩出更多花样让它更好地融入你的项目。柔化阴影边缘 目前的阴影是硬边缘。可以通过在片元着色器中基于投影顶点到原始顶点的距离或者使用一张噪声图来渐变阴影的透明度实现简单的软阴影效果。更高级的做法是使用屏幕空间技术对阴影进行模糊处理。阴影淡出 让阴影随着距离相机变远而逐渐透明。在顶点或片元着色器中根据顶点在视图空间中的深度-viewPos.z或与相机的距离计算一个淡出系数乘以阴影颜色的Alpha值。多平面阴影 如果你的场景有楼梯、斜坡等多层平面可以定义多个平面如多个Plane参数为每个平面计算一个投影矩阵然后让物体选择最近的或最合适的平面投射阴影。这需要更复杂的管理逻辑比如用射线检测来决定投影平面。与URP的Shadow Caster Pass结合 我们完全接管了阴影渲染。另一种思路是利用URP自身的ShadowCasterPass。你可以编写一个自定义的LitShader Graph在它的ShadowCasterPass 中使用我们推导的平面投影矩阵来变换顶点而不是标准的MVP变换。这样URP在渲染阴影贴图时就会使用你的平面投影逻辑。这种方法能让你的阴影接受URP内置的阴影过滤、级联阴影等高级功能但实现难度和定制灵活性会有所不同。平面阴影是一个经典的图形学技巧它在URP中的实现很好地体现了“理解管线、插入自定义操作”的渲染开发思路。它可能不是所有场景的最优解但在特定的性能或美术需求下往往能起到四两拨千斤的效果。希望这篇长文能帮你彻底吃透它下次当内置阴影不给力时你能自信地掏出这套方案自己动手丰衣足食。