尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity假阴影实现:平面投影Shader方案详解与性能优化

Unity假阴影实现:平面投影Shader方案详解与性能优化 1. 项目概述为什么我们需要“假阴影”在Unity项目里尤其是移动端、WebGL或者对性能有严格要求的项目阴影常常是性能开销的大户。你可能会发现打开实时光影后帧率FPS像坐过山车一样往下掉。实时光影Shadow Map固然效果真实但它涉及从光源视角渲染深度图、深度比较、软阴影滤波等一系列复杂计算对GPU和Draw Call都是不小的负担。这时候“假阴影”Planar Shadow就成了一种非常讨巧且高效的解决方案。它本质上不是真正的光影计算而是通过Shader将模型的顶点“压扁”并投影到一个指定的平面上通常是地面形成一个视觉上的阴影效果。开销极低一个简单的顶点变换就能搞定特别适合卡通风格、俯视角游戏、移动端轻量级项目或者作为远处物体的阴影替代方案。我最近在一个需要支持大量同屏单位的策略游戏项目里就深度用到了这套方案。当屏幕上同时存在上百个角色单位时使用真阴影简直是灾难而一个精心调校的假阴影Shader不仅帧数稳如老狗视觉上也完全能满足需求。这篇文章我就来拆解一下如何实现一个“完美”的假阴影并分享那些官方文档里不会写的实战经验和避坑指南。2. 假阴影的核心原理与方案选型在动手写代码之前我们必须搞清楚假阴影到底是怎么“变”出来的以及为什么选择当前这种实现方式。2.1 从“压扁模型”到“平面投影”假阴影最核心的思想是顶点空间变换。想象你的角色模型是一个三维的立体雕塑真实的阴影是光线被它挡住后在地面形成的黑暗区域。而我们的“造假”过程是把这个雕塑的所有顶点沿着垂直方向通常是世界空间的Y轴“拍扁”使其Y坐标全部等于地面的高度。这样一个立体的模型就变成了紧贴地面的一张“薄片”。但这还不够这只是一个正上方的俯视图投影。为了让阴影有方向感比如模拟下午的阳光我们需要对这个“薄片”在水平面XZ平面上进行拉伸和偏移。偏移的方向由虚拟的“光源方向”决定偏移的距离则与顶点原始的高度成正比——离地面越远的点比如头顶它的阴影偏移得越远。这个过程完全在顶点着色器Vertex Shader中通过数学计算完成不涉及任何光照模型和深度纹理采样因此效率极高。2.2 几种常见假阴影方案对比市面上常见的低开销阴影方案不止一种了解它们的优劣能帮助我们做出正确选择方案实现方式优点缺点适用场景脚底面片在角色脚底放一个始终朝上的圆形/方形面片附上简单材质。实现最简单开销最低。效果死板不随角色动作变形悬浮感强 immersion沉浸感差。极简风格游戏、原型开发、非常远处的物体。Projector投影器使用Unity内置的Projector组件将一张阴影贴图投射到地面上。效果较好可以产生柔边支持非平面有一定适应性。需要管理Projector的裁剪层Overdraw过度绘制可能较高移动端开销需注意。固定视角、场景地面相对平整的项目。Decal贴花类似于Projector但使用现代渲染管线如URP/HDRP的Decal系统。与渲染管线集成好性能通常优于老式Projector。需要URP/HDRP支持配置相对复杂。使用URP/HDRP的中重度项目。顶点Shader投影本文核心在Shader中通过顶点变换将模型本身渲染为一个阴影Pass。效果动态完美跟随模型顶点动画跑步、跳跃开销可控仅增加少量顶点运算无需额外GameObject。只适用于平坦或近乎平坦的接收面在斜坡、楼梯上会“穿帮”阴影浮空或嵌入。俯视角、2.5D、移动端游戏以及地面平坦的场景。经过对比对于需要阴影随角色动作如攻击、施法而自然形变且对性能敏感的项目顶点Shader投影方案无疑是平衡效果与性能的最佳选择。它完美解决了脚底面片的“僵硬”问题又避免了Projector可能带来的性能与管理复杂度。2.3 双Pass渲染架构的设计考量我们注意到示例Shader中使用了两个Pass。这是一个关键设计第一个PassShadow Pass专门负责渲染阴影。它只处理顶点位置变换输出一个纯色或简单纹理。这个Pass会使用透明度混合Blend与地面融合并可能使用模板测试Stencil来避免重叠问题。第二个PassBase Pass正常渲染模型本身的颜色和纹理。为什么要分开两个Pass为什么不直接在基础Pass里输出模型阴影原因有二渲染顺序与混合阴影必须绘制在地面之上但在模型本体之下。如果只有一个Pass很难完美控制这个夹心层的渲染顺序。双Pass架构让我们可以通过Tags {“Queue” “Geometry1”}明确指定整个物体的渲染队列稍晚于普通物体从而保证先画地面再画阴影最后画模型。材质复用与灵活性模型的本体渲染可能是复杂的包含法线贴图、高光、边缘光等。而阴影渲染极其简单。将它们分离意味着你可以随意修改甚至替换模型的主材质而不影响阴影逻辑。反之你也可以独立调整阴影的颜色、透明度、长度而无需触碰主材质。3. 核心Shader代码逐行解析与优化接下来我们深入到Shader代码内部看看每一个步骤是如何实现的以及有哪些可以优化的细节。3.1 属性定义与渲染设置Shader Custom/PlanarShadow { Properties { _MainTex (Albedo, 2D) white {} _GroundHeight (Ground Height, Float) 0 _ShadowColor (Shadow Color, Color) (0, 0, 0, 0.5) _ShadowLength (Shadow Length, Range(0, 5)) 1.0 _ShadowDirection (Shadow Direction (X,Z), Vector) (0, 0, 1, 0) }_GroundHeight这是世界空间Y轴坐标表示阴影投射的平面高度。这是实现的关键必须从脚本中根据实际地面位置动态传入。_ShadowLength阴影的拉伸长度。值越大模拟光源越低阴影越长。_ShadowDirection一个二维向量X, Z用于定义水平面上的阴影投射方向。例如(0,1)表示阴影沿Z轴正方向拉伸。我们通常会在脚本中根据灯光方向计算这个值。SubShader { Tags { QueueGeometry1 RenderTypeOpaque }“Queue”“Geometry1”这是保证正确渲染顺序的生命线。它确保了这个Shader的所有Pass会在所有不透明物体QueueGeometry渲染之后才执行。这样阴影就能正确混合在已经绘制好的地面之上。如果设置错误阴影可能会被地面遮挡或者错误地与天空盒混合。3.2 阴影Pass的顶点变换奥秘顶点着色器是假阴影的灵魂我们一步步拆解v2f vert (appdata v) { v2f o; // 1. 将顶点变换到世界空间 float4 worldPos mul(unity_ObjectToWorld, v.vertex); float originalWorldY worldPos.y; // 2. 将顶点压扁到指定地面高度 worldPos.y _GroundHeight; // 3. 计算阴影偏移量 // 顶点原始高度与地面的差值决定了偏移的“权重” float heightFactor originalWorldY - _GroundHeight; // 防止模型在地面以下的部分产生反向阴影可选也可由clip处理 heightFactor max(0, heightFactor); // 4. 应用阴影方向和长度 // 将方向向量归一化并乘以长度参数和高度权重 float3 shadowOffset float3(_ShadowDirection.x, 0, _ShadowDirection.z); shadowOffset normalize(shadowOffset) * _ShadowLength * heightFactor; // 5. 在XZ平面应用偏移 worldPos.xz shadowOffset.xz; // 6. 变换到裁剪空间 o.pos mul(UNITY_MATRIX_VP, worldPos); o.worldY originalWorldY; // 传递给片元着色器用于裁剪 return o; }关键点解析步骤1与2worldPos.y _GroundHeight;这一行就是“压扁”操作的精髓。无论模型多高所有顶点的世界Y坐标都被强制设为同一个值形成了平面投影。步骤3的heightFactor这是实现透视感的关键。一个点离地面越远比如角色的鼻子尖它的heightFactor值越大在步骤4中获得的偏移量也越大。这模拟了光源照射下高处物体阴影偏移更远的透视效果。如果没有这个因子阴影就是一个毫无立体感的纯正交投影。步骤4的方向处理我们使用一个独立的_ShadowDirection向量而不是从灯光方向推导这给了美术更大的控制权。他们可以自由调整阴影的角度以匹配场景氛围而不必改动实际灯光。3.3 片元着色器与深度/模板测试fixed4 frag (v2f i) : SV_Target { // 裁剪掉地面以下的片段例如角色脚部嵌入地面时 clip(i.worldY - _GroundHeight); return _ShadowColor; }clip(i.worldY - _GroundHeight);这是一个重要的优化和修复措施。当角色站立在斜坡或上下楼梯时部分网格如脚踝可能会低于传入的_GroundHeight。这一步会直接丢弃这些片元的渲染防止出现“从地面下长出来的阴影”这种视觉错误。在Pass中我们通常还会看到混合和模板测试设置Pass { Name Shadow Tags { LightModeForwardBase } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off // Stencil { // Ref 1 // Comp NotEqual // Pass Replace // } CGPROGRAM ...Blend SrcAlpha OneMinusSrcAlpha启用Alpha混合让阴影颜色与地面颜色混合产生半透明的阴影效果。_ShadowColor的Alpha值通常设为0.3~0.6。ZWrite Off关闭深度写入。阴影Pass不应该影响深度缓冲否则可能会错误地遮挡后面本该渲染的物体比如其他角色。模板测试Stencil被注释掉的模板测试是一个高级技巧用于解决阴影重叠问题。当两个角色的阴影交叠时简单的Alpha混合会导致叠加区域颜色变深。通过模板测试可以确保每个像素只绘制一次阴影。但这也增加了性能开销在移动端需谨慎评估。对于大多数情况关闭模板测试接受轻微的颜色叠加是更经济的选择。3.4 模型主Pass的注意事项第二个Pass就是标准的模型渲染没有特殊之处。但有一点需要注意确保阴影Pass和模型主Pass使用相同的顶点数据。如果模型使用了蒙皮动画Skinned Mesh Renderer那么计算世界坐标时必须使用动画后的顶点位置。这意味着我们需要将骨骼动画的变换矩阵也考虑到顶点着色器中或者更简单的方法——对于Skinned Mesh Renderer我们通常将其单独处理。4. 在Unity中的完整实现与集成Shader写好了但要让它在游戏里动起来还需要C#脚本的配合。4.1 动态控制脚本创建一个PlanarShadowController.cs脚本挂载到需要投射阴影的角色上。using UnityEngine; [RequireComponent(typeof(Renderer))] public class PlanarShadowController : MonoBehaviour { private Renderer _renderer; private MaterialPropertyBlock _propBlock; [SerializeField] private float _groundHeight 0f; [SerializeField] private Color _shadowColor new Color(0, 0, 0, 0.5f); [SerializeField] private float _shadowLength 1.0f; [SerializeField] private Vector3 _lightDirection new Vector3(30, 150, 0); // 欧拉角表示光源方向 void Start() { _renderer GetComponentRenderer(); _propBlock new MaterialPropertyBlock(); UpdateShadowProperties(); } void Update() { // 如果地面高度或灯光方向会动态变化每帧更新 UpdateShadowProperties(); } void UpdateShadowProperties() { if (_renderer null) return; _renderer.GetPropertyBlock(_propBlock); // 传递地面高度世界坐标 _propBlock.SetFloat(_GroundHeight, _groundHeight); // 传递阴影颜色 _propBlock.SetColor(_ShadowColor, _shadowColor); // 传递阴影长度 _propBlock.SetFloat(_ShadowLength, _shadowLength); // 将灯光方向欧拉角转换为XZ平面方向向量 Vector3 globalLightDir Quaternion.Euler(_lightDirection) * Vector3.forward; // 我们只需要水平方向X, Z并归一化 Vector2 shadowDir new Vector2(globalLightDir.x, globalLightDir.z).normalized; _propBlock.SetVector(_ShadowDirection, new Vector4(shadowDir.x, shadowDir.y, 0, 0)); _renderer.SetPropertyBlock(_propBlock); } // 在Editor中方便调试 void OnValidate() { if (Application.isPlaying _renderer ! null) { UpdateShadowProperties(); } } }脚本核心作用动态传参将_GroundHeight、_ShadowColor等属性从CPU传递到GPU的Shader。使用MaterialPropertyBlock而不是直接修改Material这是一个最佳实践。因为它允许多个渲染器共享同一个材质实例同时拥有不同的阴影参数避免了因修改材质而产生的运行时材质实例化开销。方向计算将易于理解的光源旋转角度欧拉角转换为Shader需要的二维方向向量。地面高度适配_GroundHeight通常不能是一个固定值。你需要根据角色站立的位置实时获取地面高度。这可以通过从角色脚底向下发射射线Raycast来实现从而让阴影完美贴合起伏的地形在平坦区域。4.2 地面高度检测与自适应这是实现“完美”假阴影的重中之重。一个固定的_GroundHeight只适用于绝对平坦的地面。void UpdateGroundHeight() { RaycastHit hit; // 从角色包围盒底部中心向下发射射线 Vector3 rayStart _renderer.bounds.center; rayStart.y _renderer.bounds.min.y; float rayLength 5.0f; // 检测距离 if (Physics.Raycast(rayStart, Vector3.down, out hit, rayLength, groundLayerMask)) { _groundHeight hit.point.y; } else { // 如果没有检测到地面可以设置为一个默认值或上一帧的值 // _groundHeight defaultHeight; } }将这个检测方法集成到UpdateShadowProperties中你就能实现阴影随地形起伏而“贴合”的效果。注意射线检测本身有CPU开销对于大量单位需要做性能优化比如每N帧检测一次或者对于静止的单位减少检测频率。4.3 材质与Shader的配置流程创建材质将编写好的Shader拖拽创建一个新的材质球。配置材质将材质赋给角色的Renderer组件。此时材质球上会出现我们定义的属性_GroundHeight,_ShadowColor等但它们是灰色的因为我们将通过脚本的MaterialPropertyBlock动态控制。挂载脚本将PlanarShadowController脚本挂载到角色GameObject上。设置参数在Inspector窗口中调整_lightDirection光源方向和_shadowColor等参数直到阴影效果满意。设置地面层在脚本中指定groundLayerMask确保射线检测只与地面碰撞体交互。5. 进阶优化与常见问题实战排坑掌握了基础实现后我们来看看如何让它更“完美”以及如何解决那些必然会遇到的坑。5.1 性能优化要点合批Batching中断使用MaterialPropertyBlock会打断动态合批Dynamic Batching。对于大量使用假阴影的同屏单位这可能是性能瓶颈。解决方案是尽可能使用GPU Instancing修改Shader为_GroundHeight、_ShadowColor等属性添加[PerRendererData]标签并启用GPU Instancing。这样即使参数不同相同网格和材质的物体也能被批量渲染。参数分组将阴影参数如颜色、长度近似的单位分为几组每组共享一个材质实例减少MaterialPropertyBlock的变更次数。减少Overdraw阴影Pass是叠加在场景之上的如果阴影面积很大且半透明会造成过度绘制。确保阴影颜色的Alpha值不要过高通常0.5以下并且对于被遮挡的阴影如角色在墙后可以考虑通过脚本动态禁用阴影渲染。针对移动端的优化精度降低在移动端Shader中将float改为halffixed以减少寄存器压力和带宽。避免复杂运算sin,cos计算相对昂贵。如果阴影方向固定可以在脚本中计算好方向向量传入避免在Shader中进行角度到弧度的转换和三角函数计算。简化顶点数据阴影Pass的appdata结构体可以只包含顶点位置POSITION不需要法线、UV等数据。5.2 视觉效果提升技巧边缘柔化软阴影纯色块阴影看起来很“硬”。可以在片元着色器中根据像素距离阴影“中心”的距离让Alpha值从中心向边缘逐渐衰减。// 在阴影Pass的片元着色器中 float4 frag (v2f i) : SV_Target { clip(i.worldY - _GroundHeight); // 假设i.uv是投影平面的UV或者通过世界坐标计算一个衰减因子 float softFactor 1.0 - smoothstep(0.0, _SoftEdgeWidth, distanceToCenter); fixed4 col _ShadowColor; col.a * softFactor; return col; }这需要额外的数据传递如投影平面的UV实现起来稍复杂但能极大提升视觉品质。阴影淡出Fade-out当角色离摄像机很远时阴影可以逐渐变淡甚至消失。这可以通过在脚本中计算角色与摄像机的距离动态调整_ShadowColor.a来实现。接收阴影物体的凹凸处理假阴影是平面的如果地面有凹凸细节如鹅卵石路面阴影会穿帮。一个妥协方案是使用一张简单的、与地面法线贴图相关的灰度图在阴影颜色上乘以一个系数模拟凹凸对阴影的微弱影响但这属于非常进阶的用法。5.3 典型问题与解决方案实录问题一阴影在斜坡或楼梯上“浮空”或“嵌入”。现象角色上下坡时阴影要么飘在空中要么卡进地面。根因这是平面投影方案的固有缺陷。它假设接收面是一个完美的平面。解决方案动态地面高度如前所述使用射线检测获取角色脚下精确的_GroundHeight。这能解决单点接触的“贴合”问题但对于整个模型投影到一个斜面依然不完美。分片投影复杂将模型网格分成若干部分如躯干、四肢分别计算其投影高度。这需要修改模型或使用更复杂的Shader开销大增。接受局限设计规避这是最实用的方法。在关卡设计时让需要精确阴影互动的区域如战斗区域尽量保持平坦。在斜坡区域可以适当缩短阴影长度或降低不透明度减弱违和感。问题二多个阴影重叠时颜色过深。现象两个角色站在一起阴影重叠区域变成深黑色。根因半透明混合Alpha Blending的叠加效应。解决方案使用模板测试Stencil如之前代码注释所示启用模板缓冲确保每个像素只绘制一次阴影。这是最“正确”的图形学解决方案。性能权衡模板测试会增加带宽开销。在移动端如果重叠情况不严重轻微的变暗是可以接受的甚至能增加一点层次感。我的经验是在低端移动设备上优先关闭模板测试。问题三阴影Pass遮挡了后续渲染的透明物体。现象比如角色站在一个透明玻璃窗后阴影却绘制在了玻璃前面。根因阴影Pass的渲染队列Geometry1可能早于透明物体队列Transparent。解决方案调整阴影Pass的渲染队列为“Queue”“Transparent”并确保其渲染顺序在真正的模型之后。但这可能会引入与其他透明物体的混合顺序问题需要根据项目具体情况调整。问题四Skinned Mesh Renderer蒙皮网格渲染器阴影扭曲。现象使用骨骼动画的角色其阴影形状怪异不跟随动画变形。根因标准的顶点着色器mul(unity_ObjectToWorld, v.vertex)使用的是静态的世界矩阵没有考虑骨骼变换。解决方案为阴影Pass启用蒙皮动画支持。这通常意味着你需要复制一份支持SKINNED_ON关键字的顶点着色器代码或者使用Surface Shader并开启addshadow指令但会复杂很多。一个更简单的替代方案是对于Skinned Mesh Renderer不使用这个Shader而是为其单独创建一个简单的、跟随脚底的阴影面片虽然效果稍差但稳定可靠。实现一个“完美”的假阴影从来不是一劳永逸地套用一段Shader代码。它需要你根据项目类型是俯视角MMO还是横版过关、目标平台高端PC还是千元安卓机、美术风格写实还是卡通做出大量的权衡和调校。理解其原理掌握动态传参、地面适配、性能优化这些核心技巧你就能让这个简单的技术在项目中发挥出远超其复杂度的价值。最终你会发现最“完美”的方案永远是那个最契合你项目当下需求的方案。
返回列表