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。这是一个关键设计:
- 第一个Pass(Shadow Pass):专门负责渲染阴影。它只处理顶点位置变换,输出一个纯色(或简单纹理)。这个Pass会使用透明度混合(Blend)与地面融合,并可能使用模板测试(Stencil)来避免重叠问题。
- 第二个Pass(Base Pass):正常渲染模型本身的颜色和纹理。
为什么要分开两个Pass?为什么不直接在基础Pass里输出模型+阴影?原因有二:
- 渲染顺序与混合:阴影必须绘制在地面之上,但在模型本体之下。如果只有一个Pass,很难完美控制这个夹心层的渲染顺序。双Pass架构让我们可以通过
Tags {“Queue” = “Geometry+1”}明确指定整个物体的渲染队列稍晚于普通物体,从而保证先画地面,再画阴影,最后画模型。 - 材质复用与灵活性:模型的本体渲染可能是复杂的,包含法线贴图、高光、边缘光等。而阴影渲染极其简单。将它们分离,意味着你可以随意修改甚至替换模型的主材质,而不影响阴影逻辑。反之,你也可以独立调整阴影的颜色、透明度、长度,而无需触碰主材质。
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 { "Queue"="Geometry+1" "RenderType"="Opaque" }“Queue”=“Geometry+1”:这是保证正确渲染顺序的生命线。它确保了这个Shader的所有Pass会在所有不透明物体(Queue=Geometry)渲染之后才执行。这样,阴影就能正确混合在已经绘制好的地面之上。如果设置错误,阴影可能会被地面遮挡,或者错误地与天空盒混合。
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与2:
worldPos.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 { "LightMode"="ForwardBase" } 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 = GetComponent<Renderer>(); _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的变更次数。
- 尽可能使用GPU Instancing:修改Shader,为
减少Overdraw:阴影Pass是叠加在场景之上的,如果阴影面积很大且半透明,会造成过度绘制。确保阴影颜色的Alpha值不要过高(通常0.5以下),并且对于被遮挡的阴影(如角色在墙后),可以考虑通过脚本动态禁用阴影渲染。
针对移动端的优化:
- 精度降低:在移动端Shader中,将
float改为half,fixed,以减少寄存器压力和带宽。 - 避免复杂运算:
sin,cos计算相对昂贵。如果阴影方向固定,可以在脚本中计算好方向向量传入,避免在Shader中进行角度到弧度的转换和三角函数计算。 - 简化顶点数据:阴影Pass的appdata结构体可以只包含顶点位置
POSITION,不需要法线、UV等数据。
- 精度降低:在移动端Shader中,将
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的渲染队列(Geometry+1)可能早于透明物体队列(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还是千元安卓机?)、美术风格(写实还是卡通?)做出大量的权衡和调校。理解其原理,掌握动态传参、地面适配、性能优化这些核心技巧,你就能让这个简单的技术,在项目中发挥出远超其复杂度的价值。最终你会发现,最“完美”的方案,永远是那个最契合你项目当下需求的方案。