ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity Shader阴影原理与实战:ShadowCaster、接收阴影及URP实现

Unity Shader阴影原理与实战:ShadowCaster、接收阴影及URP实现 写Shader的时候最让人抓狂的是什么不是写不出炫酷的效果而是明明渲染得挺正常一开阴影全露馅物体不产生阴影或者产生了阴影但死活接收不到其他物体的阴影。你翻遍文档发现大多数人只会建议你加一句FallBack Diffuse然后你加了阴影确实出来了可一旦你开始用顶点动画、做透明镂空、或者换成URP管线这套土办法马上就失灵。这次我把“产生阴影”和“接收阴影”这条路彻底走一遍从Shadow Map的底层逻辑讲到两个可以用在项目里的完整Shader再附上我踩过的一个个大坑。文章定位是入门到进阶适合已经写过基本Unlit Shader、但对阴影系统一直一知半解的人。1. 阴影在Unity里到底是怎么“画”出来的1.1 阴影贴图背后的两趟渲染先别急着抄代码把原理搞清楚后面所有坑你都能自己推断出来。Unity做实时阴影核心思路是Shadow Map阴影贴图。说白了就是把摄像机的位置挪到光源那里从光源的视角把整个场景渲染一遍。但这趟渲染不画颜色、不画纹理只写深度值得到一张“灯光视角的深度图”。这张图里每个像素记录的是“从光源到第一个挡光物体的距离”。第二步回到主摄像机正常渲染场景。对于场景里的每一个像素我们把它从世界坐标转换到光源视角的坐标空间拿这个坐标去刚才那张深度图里采样得到一个“光源看到的深度值”。如果当前像素的实际距离大于这个采样值说明这个像素被别的物体挡住了那它就在阴影里。这就是Shadow Map最朴素的原理。Unity把这两趟渲染封装得很好你在Frame Debugger里能看到一个叫RenderShadowmap的步骤就是干第一趟活。这时你大概明白了任何一个想要“产生阴影”的物体都必须在那第一趟渲染里被画进阴影贴图。而Unity在渲染阴影贴图时用的不是你的Main Pass而是专门的ShadowCaster Pass。1.2 为什么自定义Shader默认“没阴影”很多人写自定义Shader写了Pass { ... }里面就是CGPROGRAM加顶点片元函数结果物体不产生阴影。原因很简单你的Shader里没有ShadowCaster PassUnity在渲染阴影贴图时找不到这个Pass就默认这个物体不参与阴影投射。那为什么Unity创建标准表面着色器时你什么都没干就有了阴影因为表面着色器是Unity自动帮你生成了ShadowCaster Pass。同样FallBack Diffuse的作用也不是“让Shader更兼容”而是告诉Unity在我自己的Pass里找不到ShadowCaster时就跑去Diffuse这个Shader里找。Diffuse内置了ShadowCaster所以阴影就出来了。这一步是整篇文章的基石产生阴影本质上就是在写或者借一个ShadowCaster Pass接收阴影本质上是在Main Pass里通过内置宏去采样阴影贴图再把结果乘到最终颜色上。2. 产生阴影ShadowCaster Pass实战2.1 最简单方案直接FallBack如果你只是做一个小Demo主Pass简单到不行那直接加一行FallBack确实最省事Shader Custom/MyFirstShadow { SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { return fixed4(1, 1, 1, 1); } ENDCG } } FallBack Diffuse }这个白模Shader立刻就有了投影能力。但要注意FallBack是最后一道保险它有一个隐藏问题如果你的主Pass里有顶点动画、透明度测试之类的操作Diffuse的ShadowCaster可不知道这些它只会傻乎乎地按原始顶点位置计算深度。结果是物体本身在摇摆地上的影子却纹丝不动或者镂空物体的影子是一整块实心的这就是大量草丛、栅栏Shader阴影不对的根源。2.2 自定义ShadowCaster Pass的标准写法要解决上面的问题就得自己写ShadowCaster Pass在投影阶段复制一遍主Pass里的顶点变换和裁剪逻辑。以带透明度测试的叶子Shader为例完整写法长这样Shader Custom/LeafWithShadow { Properties { _MainTex (Texture, 2D) white {} _Cutoff (Alpha Cutoff, Range(0, 1)) 0.5 } SubShader { Tags { RenderTypeOpaque QueueAlphaTest } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; float _Cutoff; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); clip(col.a - _Cutoff); return col; } ENDCG } Pass { Name ShadowCaster Tags { LightMode ShadowCaster } CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster #include UnityCG.cginc sampler2D _MainTex; float _Cutoff; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; v2f vertShadow (appdata v) { v2f o; o.uv v.uv; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 fragShadow (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); clip(col.a - _Cutoff); SHADOW_CASTER_FRAGMENT(i) } ENDCG } } FallBack Off }逐行说几个关键点。Tags { LightMode ShadowCaster }是这个Pass的身份标识Unity在阴影贴图阶段会找到这个Tag并渲染它。#pragma multi_compile_shadowcaster是Unity预设的阴影相关关键字集合它能正确处理不同平台下的阴影偏移和多种灯光类型。TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)是UnityCG.cginc里的内置宏作用是把顶点坐标转换到裁剪空间同时处理阴影偏移的Normal Bias避免阴影产生“麻点”自遮挡。片元函数里SHADOW_CASTER_FRAGMENT(i)则会把计算好的深度值写到阴影贴图里。这套宏其实就是Unity内置ShadowCaster Pass的标准实现你把它抄过来再在片元阶段加上自己的clip逻辑即可。2.3 容易踩坑包围盒与顶点动画这一段值得单独拿出来讲因为它在实际项目里坑了无数人。Unity在渲染阴影贴图时会先判断物体在灯光空间中的包围盒范围。如果包围盒计算不准物体即使写对了ShadowCaster Pass也可能出现“阴影偶尔消失”、“阴影闪烁”、“离近了才有影子”这类诡异现象。什么情况下包围盒会不准最常见的就是顶点动画。比如你在顶点着色器里把顶点沿法线方向拉伸了一大截但Mesh的原始包围盒还是原来的小盒子。主Pass渲染时顶点飞出包围盒Unity会把它当成屏幕外裁剪这就是很多旗帜、草地Shader“明明顶点动了阴影却一动不动”的深层原因。解决方案也很直接在脚本里根据动画范围手动放大Renderer的包围盒。using UnityEngine; [RequireComponent(typeof(Renderer))] public class ExpandBounds : MonoBehaviour { public Vector3 expandSize new Vector3(1, 2, 1); private Renderer _renderer; void Start() { _renderer GetComponentRenderer(); Bounds bounds _renderer.bounds; bounds.Expand(expandSize); _renderer.bounds bounds; } }这个脚本挂在带顶点动画的物体上阴影贴图阶段Unity就会用这个扩大后的包围盒来判断投影范围。如果你在Shader里用了_BBoxCenter、_BBoxExtents这类参数做自定义包围盒逻辑也是一样Shader负责算脚本负责喂。注意修改Renderer.bounds是只读的正确的做法是先拿到_renderer.bounds修改后重新赋回去直接改bounds.size会报错。3. 接收阴影让自定义Shader“吃”到阴影3.1 内置宏三件套的原理产生阴影解决了接下来是接收阴影。接收阴影的意思是你的物体本身能被别的物体挡住从而在自身上显示出一片暗色区域。在Unity内置渲染管线的自定义Shader里接收阴影最标准的做法是用这三个宏SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATION。它们的作用分别是在结构体里声明阴影坐标、在顶点着色器里计算阴影坐标、在片元着色器里采样阴影贴图并得到衰减值。为什么不能用一句tex2D(_ShadowMapTexture, ...)直接采样因为阴影贴图的分辨率、灯光投影矩阵、屏幕空间映射关系都不需要你关心Unity把这些全部封装在AutoLight.cginc里而这三个宏会根据当前光照条件自动选择正确的实现方式。比如平行光的阴影贴图是在屏幕空间重投影的点光源要采样立方体贴图这些差异都藏在TRANSFER和ATTENUATION宏的内部。3.2 一个能直接用的Unlit接收阴影Shader下面给你一个可以直接拖到项目里用的完整Shader它同时包含了上面讲的自定义ShadowCaster和一个能接收阴影的Main PassShader Custom/UnlitShadowReceiver { Properties { _MainTex (Texture, 2D) white {} } SubShader { Tags { RenderTypeOpaque } Cull Off Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include AutoLight.cginc sampler2D _MainTex; float4 _MainTex_ST; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; SHADOW_COORDS(1) }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); TRANSFER_SHADOW(o) return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); fixed shadow SHADOW_ATTENUATION(i); col.rgb * shadow; return col; } ENDCG } Pass { Name ShadowCaster Tags { LightMode ShadowCaster } CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster #include UnityCG.cginc float4 vertShadow (float4 vertex : POSITION) : SV_POSITION { return UnityObjectToClipPos(vertex); } fixed4 fragShadow () : SV_Target { return 0; } ENDCG } } }这个Shader的主Pass是Unlit风格但加入了阴影接收能力。注意第12行的#pragma multi_compile_fwdbase这行编译指令会展开所有ForwardBase光照路径下的阴影关键字是TRANSFER_SHADOW和SHADOW_ATTENUATION能正确工作的前提。SHADOW_COORDS(1)里的数字1代表使用TEXCOORD1这个插值寄存器来存储阴影坐标。如果你的Shader里TEXCOORD0已经给了uv那就从1开始依次往后排不要重复使用。片元里乘完shadow之后物体就会根据自己是否处于其他物体的阴影中而变暗。如果还想叠加光照颜色可以在乘阴影之前加上漫反射计算例如col.rgb * (shadow * _LightColor0.rgb * diff ambient);不过那就属于光照模型的扩展了这里不再展开。3.3 注意表面着色器和纯手写是两回事很多人在Surface Shader里用#pragma surface surf Lambert时阴影接收是默认就有的感觉很简单。但一旦换成手写Unlit Shader就发现完全不生效。这背后是两套阴影数据链路。Surface Shader是Unity帮你生成了一大堆代码包括光照计算、阴影坐标传递、ShadowMap采样全都在后台做好了。手写Shader则必须自己补齐这些链路。所以千万不要在Surface Shader里发现问题后把代码抄进Unlit Shader还没头没尾地漏了三个宏然后到处问为什么没阴影。如果你已经用了无光照Shader又想让物体接收阴影还有一条更省事的路径把#pragma vertex vert升级为#pragma surface surf Lambert这种表面着色器写法然后保留自定义的顶点函数。Surface Shader会自动处理阴影相关的一切你只需要在顶点函数里做你想要的特效即可。4. URP下阴影的生成与接收写法4.1 URP的ShadowCaster Pass现在新项目基本都用URP了URP里的阴影原理和内置管线一致但Shader写法差异很大最明显的区别是CGPROGRAM换成了HLSLPROGRAM内置函数也全面换血。URP里要产生阴影同样需要一个ShadowCaster Pass。下面是URP下最简版本的投影PassPass { Name ShadowCaster Tags { LightMode ShadowCaster } HLSLPROGRAM #pragma vertex ShadowPassVertex #pragma fragment ShadowPassFragment #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings ShadowPassVertex(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); output.uv input.uv; return output; } half4 ShadowPassFragment(Varyings input) : SV_TARGET { return 0; } ENDHLSL }这里最关键的是第三行Tags { LightMode ShadowCaster }以及顶点变换使用的TransformObjectToHClip。理论上这就够了URP的阴影渲染系统会负责往里写深度。如果你要做透明镂空阴影在片元函数里采样贴图并clip掉透明像素即可逻辑和内置管线一模一样。4.2 URP接收阴影的宏与光照模型URP里接收阴影不再用SHADOW_ATTENUATION那套老宏而是有自己的一套API。最常用的方式是引入Lighting.hlsl然后调用GetMainLight和ComputeShadow相关函数。以URP的Unlit Shader为例接收阴影的核心代码是#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float2 uv : TEXCOORD1; float4 shadowCoord : TEXCOORD2; }; float4 GetShadowCoord(Varyings input) { return TransformWorldToShadowCoord(input.positionWS); } half4 Fragment(Varyings input) : SV_Target { half4 color SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); Light mainLight GetMainLight(TransformWorldToShadowCoord(input.positionWS)); color.rgb * mainLight.shadowAttenuation; return color; }GetMainLight会帮你填充主光源的方向、颜色、阴影衰减值。其中shadowAttenuation就是我们要的阴影遮挡系数范围0到10代表完全在阴影里。把颜色乘上这个系数阴影接收就完成了。注意在URP里如果Shader没有接收阴影不要只怪Shader先检查项目里URP Asset的Shadows选项卡是不是勾选了Directional Light Shadows以及Shadow Distance是否设得太小。URP的阴影距离是按米算的默认可能只有几十米远处的物体没有阴影是正常的。5. 阴影问题排查清单实战向5.1 常见现象与原因对照我在项目里排查阴影问题时一般先看现象然后按下面这张表格逐一核对效率很高。现象可能原因排查方向物体完全不产生阴影Shader没有ShadowCaster Pass且FallBack OffFrame Debugger看ShadowMap阶段有没有这个物体阴影在远处消失项目阴影距离设置太小Project Settings Quality Shadows Shadow Distance阴影闪烁有大量黑点阴影偏移不合适或ShadowCaster缺少Normal Offset调大Bias和Normal Bias检查是否用了TRANSFER_SHADOW_CASTER_NORMALOFFSET物体顶点动画动阴影不动阴影Pass没有同步顶点变换或包围盒太小在ShadowCaster里写同样的顶点动画逻辑并脚本扩大Bounds镂空物体阴影是实心块ShadowCaster没有做clip在ShadowCaster片元里采样纹理并clip明明在阴影里却不接收Main Pass没加#pragma multi_compile_fwdbase检查编译指令是否齐全物体半透明但阴影是实心半透明Shader没处理ShadowCaster的clip按透明通道做clip或使用Fade模式专用Pass5.2 排查顺序建议遇到阴影问题别闷头改Shader我推荐按这个顺序来第一步开Frame Debugger搜RenderShadowmap看这个物体有没有出现在阴影贴图的渲染批次里。如果压根没出现问题出在“物体没有参与阴影渲染”先查Pass的Tag和FallBack。第二步如果出现在了阴影贴图里但屏幕上看不到阴影问题出在“阴影贴图采样阶段”。先检查Main Pass是否写了接收阴影的宏再查Quality设置里的Shadow Distance。第三步阴影有但是形状不对、位置偏移、闪烁。这是ShadowCaster Pass内部实现的细节问题重点检查顶点变换是否和主Pass一致偏移量设置是否合理包围盒是否足够大。提示查阴影问题最容易忽略的是Light组件的Shadow Type。很多场景里灯光默认Shadow Type是No Shadows你Shader写得再正确也没用。这是最低级的错误但经常发生。另外一个实战技巧如果你在URP里临时调试阴影可以右键创建URP的Lit Shader把它的效果和你的Shader对比。如果Lit能出阴影而你的出不来就去看Lit生成的ShadowCaster Pass和你自己写的差在哪这是最直观的对照实验。6. 最后分享一点个人经验做Shader开发这么久我的体会是阴影问题九成都是“链路断裂”不是算法多难。产生阴影要保证三条链路都通一是Shader里有正确的ShadowCaster Pass二是物体包围盒覆盖了所有顶点运动范围三是灯光开启了Shadow Type。接收阴影要保证两条链路都通一是Main Pass正确声明了阴影坐标和相关宏二是当前渲染路径走到了ForwardBase或URP对应的光照Pass。还有一个特别容易被坑的点某些优化插件会主动Strip掉你不用的Pass。比如你在Shader里写了ShadowCaster但Shader被标记为不受阴影影响或者被某个优化工具自动清理了最终运行的版本里其实没有这个Pass。所以排查时建议在Frame Debugger里看到的Shader Keywords和Pass列表为准别光信自己在代码里写的。如果在实际项目里仍然遇到阴影相关的疑难杂症建议先把场景简化到只有一个平行光、一个投影方块、一个接收平面然后逐步往Shader里加变量和功能每加一步看一次阴影效果。这种“二分定位法”虽然笨但在复杂的项目里比瞎猜有效得多。我自己就是靠这套方法啃下了好几个因为Shader变体太多而出现的阴影缺失问题。希望这篇内容能帮你少走一点弯路。
返回列表