Unity自定义Shader阴影消失?彻底搞懂ShadowCaster实现投射与接收
很多人刚开始自己写Unity Shader时都会撞上一堵墙从Asset Store拖下来的模型换上自己写的Unlit Shader地面上干干净净影子没了。我当时也干过这事翻来覆去调Lighting设置、检查Renderer死活想不明白最后才发现问题根本不在场景而在Shader自己少写了一个Pass——也就是标题里提到的shadowCast。这篇就把产生阴影和接收阴影这件事彻底拆开从阴影映射原理讲到可以直接抄的Shader代码再聊几个我实际项目里踩过的坑。想搞懂Unity阴影原理、或者正被自定义Shader阴影问题卡住的人这篇都能给你一个能落地的答案。1. 阴影是怎么画出来的Caster与Receiver的分工很多人以为Unity的阴影是引擎顺手生成的模型放那影子自然就有。实际上实时阴影远没有这么智能它本质上是一种以空间换时间的近似算法背后是一整套Shadow Mapping流程。1.1 Shadow Map先把谁被光源照亮变成一张深度图实时阴影最常用的方案是Shadow Mapping中文叫阴影映射。思路非常直白从光源位置放一台虚拟摄像机朝场景拍一张深度图记录它看到的每一个点的深度。这张深度图就是Shadow Map可以理解为从光源视角看过去最前面那层物体离光源多远。到了正常渲染阶段GPU渲染物体时会把当前片元也变换到光源空间然后取出Shadow Map里对应位置的深度值做一次比较如果当前片元离光源更远说明它被某个更靠近光源的物体挡住了于是判定为阴影如果深度差不多或者更近说明它直接暴露在光线下就不在阴影里。就这么一个简单的思想支撑起了当今绝大多数实时渲染引擎的阴影效果。Unity里这套流程被高度封装你看不到每次阴影渲染的具体过程但幕后它就是按这个逻辑走的。方向光、聚光灯、点光源都走Shadow Map只是投影矩阵和深度计算方式略有差异。需要注意的是渲染Shadow Map时引擎并不会把你屏幕上看到的模型完整画一遍它只关心深度颜色一概忽略。1.2 Unity里Cast和Receive是两趟独立的Drawing在Unity里阴影被拆成两个独立环节产生阴影Cast和接收阴影Receive。产生阴影对应的是引擎在渲染Shadow Map阶段需要把物体画进深度图里。这一步必须执行Shader中的一个特殊Pass也就是LightMode标记为ShadowCaster的Pass。如果Shader里找不到这个Pass或者材质没有Fallback物体在Shadow Map里就是透明的自然不会产生阴影。接收阴影对应的是正常渲染物体时在当前物体的Pass里采样Shadow Map并和片元深度做比较算出阴影衰减值。这一步通常发生在ForwardBase或ForwardAdd这样的光照Pass中需要Shader里有一系列专门的宏参与。这两件事是独立的A物体可以只投射不接收B物体也可以只接收不投射。Mesh Renderer上的Cast Shadows和Receive Shadows两个开关分别控制着这两个方向。很多新手只注意了场景灯光里有没有开阴影却忽略了Renderer和Shader两侧的状态这就是阴影莫名其妙消失的头号原因。环节实际发生的阶段依赖的Shader PassRenderer开关产生阴影渲染Shadow Map时LightModeShadowCaster的PassCast Shadows接收阴影正常渲染物体时光照PassForwardBase/ForwardAddReceive Shadows理解了这个分工就明白为什么自定义Shader最容易出问题——它可能只有普通的颜色Pass根本没有为产生阴影这件事准备任何东西。2. 物体不投影的根本原因Shader里没有ShadowCaster Pass先看一个最常见的场景你用Shader Graph或者手写代码新建了一个Unlit Shader模型显示正常颜色也对但投射阴影选项怎么开都没效果。说破大天就是因为这个Shader压根没有ShadowCaster Pass。2.1 症状物体凭空消失的阴影在Unity内置渲染管线里一个很简单的Unlit Shader可能长这样Shader Custom/MyUnlit { SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc 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, 0, 0, 1); } ENDCG } } }这个Shader没有任何LightMode标记也没有Fallback。Unity渲染Shadow Map时拿着这个材质找ShadowCaster Pass找遍SubShader都找不到于是模型就不参与Shadow Map渲染。屏幕上模型红彤彤地站着地上却连个轮廓都没有。这时候如果你打开Frame Debugger看阴影图渲染那一步会发现DrawCall列表里压根没有这个物体的条目。可惜大多数人在排查时第一反应是调灯光强度、调Shadow Distance绕了一大圈问题其实就出在Shader自己。2.2 Fallback能救急但救不了顶点动画遇到这种情况很多人的第二个反应是给Shader加一行Fallback。Fallback VertexLit加上之后Unity在找不到ShadowCaster Pass时会回退到Fallback指定的Shader从那里找ShadowCaster Pass继续执行。于是阴影出来了问题看起来解决了。但这种救急方案在后头很容易埋雷。最典型的坑有两个第一顶点动画对不上。如果你的Shader在顶点阶段做了位移比如草随风摆动、旗帜飘动、角色衣服跟着骨骼动画顶点的逻辑变化而Fallback的内置Shader没有这一段顶点位移逻辑阴影就会按照模型原始姿态去投出现影子在空中影子和模型分离这种诡异画面。第二镂空裁剪失效。树的树叶、铁栅栏这类需要用clip()裁剪透明像素的材质用了Fallback后内置ShadowCaster里没有你的clip逻辑阴影直接变成一个实心方块。远处看一棵镂空的树地面阴影却是一团圆乎乎的实心黑影非常出戏。实际上Fallback的正确使用场景是你这个Shader真的没有特殊裁剪和顶点动画只是一个普通的实体模型。但只要你动了顶点位置或者在片段阶段做了clip就必须考虑写自定义ShadowCaster Pass。3. 手写ShadowCaster Pass让阴影跟着顶点动画和透明边缘走既然Fallback靠不住那就自己把ShadowCaster Pass写出来。这一步听着玄乎实际代码量非常小关键是要理解里面每个宏在干什么。3.1 最基础的ShadowCaster Pass模板内置管线中一个最常见的ShadowCaster Pass长这样Pass { Name ShadowCaster Tags { LightMode ShadowCaster } ZWrite On ZTest LEqual Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include UnityCG.cginc struct v2f { V2F_SHADOW_CASTER; }; v2f vert (appdata_base v) { v2f o; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 frag (v2f i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG }你不用管V2F_SHADOW_CASTER展开后到底是什么结构只需要知道它是为一个ShadowCaster专门准备的顶点到片段的数据结构。TRANSFER_SHADOW_CASTER_NORMALOFFSET这个宏做了两件事把顶点从模型空间变换到裁剪空间同时叠加一个基于法线方向的偏移量。这个偏移量就是Normal Bias它可以把阴影表面稍微往光源方向推一点避免阴影粉刺画面上一闪一闪的密集黑点。#pragma multi_compile_shadowcaster也很重要它会在不同平台和不同灯光类型下编译出对应的Shader变体。Unity的ShadowCaster在处理方向光、聚光灯、点光源时有些计算路径不一样这个编译指令帮你把变体一次性备齐。片段阶段几乎不用做计算直接返回0即可因为Shadow Map只关心深度值不关心颜色。我把这段代码塞进一个带Properties的完整Shader里需要时直接复制用。3.2 把Clipping放进来镂空阴影的正确姿势如果你的材质需要镂空效果ShadowCaster Pass就多了一个操作在片段阶段执行和主Pass一致的clip。拿树叶来举例主Pass会采样透明度贴图使用clip(alpha - _Cutoff)把透明部分丢掉。ShadowCaster里也必须做同样的事情否则又回到透明部分也投实心阴影的老路。sampler2D _MainTex; float4 _MainTex_ST; fixed _Cutoff; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; v2f vert (appdata_base v) { v2f o; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) o.uv TRANSFORM_TEX(v.texcoord, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 tex tex2D(_MainTex, i.uv); clip(tex.a - _Cutoff); SHADOW_CASTER_FRAGMENT(i) }关于TEXCOORD的编号需要留个心眼。V2F_SHADOW_CASTER本身已经占了一组坐标所以我这里把uv放在TEXCOORD1避免冲突。clip函数一旦执行满足条件的片元会被直接丢弃不会写入Shadow Map。这样镂空部分在阴影里也是镂空的阳光穿过树叶缝隙洒下来的影子才真实。3.3 顶点动画怎么让阴影跟着动顶点动画场景下ShadowCaster的修改更简单粗暴在vert阶段把主Pass里那套顶点偏移逻辑再执行一遍。比如一棵被风吹动的草主Pass里对顶点做了这样的弯曲float3 offset float3(sin(_Time.y v.vertex.x * 2.0), 0, 0) * _WindStrength; v.vertex.xyz offset;那么在ShadowCaster的vert里你必须在调用TRANSFER_SHADOW_CASTER_NORMALOFFSET之前先做一模一样的顶点位移。这样阴影的轮廓才会跟随草的实际弯曲状态。很多项目里草来回摆地面阴影却像钉子一样钉在原地就是因为这步缺失。但这里引出一个新问题也是很多做了多年开发的老手偶尔也会翻车的地方即使ShadowCaster里做了顶点偏移如果Renderer的包围盒没有跟着扩大Unity在视锥剔除阶段可能把物体整个丢掉阴影自然也没了。这个坑我在后面专门讲。4. 接收阴影的写法在光照Pass里采样Shadow MapShader能产生阴影只是第一步真正让物体表面变暗的是接收阴影环节。如果接收端没写对物体依然一片光亮阴影效果同样出不来。4.1 几个宏搞定阴影采样SHADOW_COORDS / TRANSFER_SHADOW / SHADOW_ATTENUATION内置管线的接收阴影逻辑其实被AutoLight.cginc封装得非常友好。你需要在光照Pass中做三件事。第一在顶点到片元的结构体中声明阴影坐标struct v2f { float2 uv : TEXCOORD0; SHADOW_COORDS(1) float4 pos : SV_POSITION; };SHADOW_COORDS(1)展开后是一个_ShadowCoord成员存放在TEXCOORD1。这里不要和UV冲突UV占TEXCOORD0阴影坐标就从TEXCOORD1开始。第二在顶点Shader中通过TRANSFER_SHADOW宏把坐标传过去v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); TRANSFER_SHADOW(o) return o; }这个宏内部会根据平台差异正确计算Shadow Map采样用的屏幕坐标或光源空间坐标。第三在片段Shader中调用SHADOW_ATTENUATION得到阴影衰减值fixed4 frag (v2f i) : SV_Target { fixed shadow SHADOW_ATTENUATION(i); // shadow为0表示完全在阴影里1表示完全在光照下 return fixed4(shadow, shadow, shadow, 1); }SHADOW_ATTENUATION返回的就是一个0到1的黑白因子你可以直接把它乘进颜色里。对于没有复杂光照计算的Unlit Shader这招尤其好用——不需要引入完整的光照模型就能让物体吃到来自方向光的阴影。4.2 一个能用的接收阴影Shader示例下面给一个完整的、在Unity内置渲染管线里可以直接跑通的接收阴影Shader它包含最简单的漫反射光照同时接收主光源方向光的阴影Shader Custom/ShadowReceiverDemo { Properties { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) } SubShader { Pass { Tags { LightMode ForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; SHADOW_COORDS(2) }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); TRANSFER_SHADOW(o) return o; } fixed4 frag (v2f i) : SV_Target { fixed shadow SHADOW_ATTENUATION(i); fixed3 worldNormal normalize(i.worldNormal); fixed3 lightDir normalize(_WorldSpaceLightPos0.xyz); fixed ndotl saturate(dot(worldNormal, lightDir)); fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb; fixed3 diffuse _LightColor0.rgb * ndotl * shadow; fixed4 tex tex2D(_MainTex, i.uv); fixed3 finalColor (ambient diffuse) * tex.rgb * _Color.rgb; return fixed4(finalColor, 1); } ENDCG } } }这段代码里#pragma multi_compile_fwdbase同样关键。它告诉编译器要生成ForwardBase需要的一系列变体其中就包括是否启用阴影。如果漏掉这个编译指令Unity会自动跳过阴影关键字的变体SHADOW_ATTENUATION展开后很可能等于一个空值最终画出来就是完全没有阴影。如果你的Shader是纯Unlit不想算光照那你完全可以把上面代码里的Diffuse部分去掉只保留ambient和shadow相乘的效果。但注意纯Unlit接收阴影在严格意义上是一种手动画上去的模拟物体之间如果有半透明穿插效果只能说够用不是物理级正确。5. URP迁移ShadowCaster从CG到HLSL的变化Unity官方已经把重心全面转到URP/HDRP上新项目很少有人再写内置管线Shader了。URP虽然保留了LightModeShadowCaster这套Tag但内部的写法从CG宏变成了一套更显式的HLSL函数。这一节单独展开方便迁移项目的朋友少踩坑。5.1 URP的ShadowCasterTag没变语言和宏换了URP里手写ShadowCaster Pass结构上依然是一个带Tags { LightMode ShadowCaster }的Pass。不同之处在于不再用TRANSFER_SHADOW_CASTER_NORMALOFFSET这种封装宏而是自己封装一个GetShadowPositionHClip函数调用URP阴影库里的ApplyShadowBias。下面是一个URP下带顶点动画和裁切能力的ShadowCaster模板Pass { Name ShadowCaster Tags { LightMode ShadowCaster } ZWrite On ZTest LEqual ColorMask 0 Cull Off 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; }; sampler2D _MainTex; float4 _MainTex_ST; fixed _Cutoff; float4 GetShadowPositionHClip(Attributes input) { float3 positionWS TransformObjectToWorld(input.positionOS.xyz); float3 normalWS TransformObjectToWorldNormal(input.normalOS); // 这里插入顶点动画比如草的弯曲 positionWS.x sin(_Time.y positionWS.x * 2.0) * 0.1; float4 positionCS TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, _LightDirection)); #if UNITY_REVERSED_Z positionCS.z min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return positionCS; } Varyings ShadowPassVertex(Attributes input) { Varyings output; output.positionCS GetShadowPositionHClip(input); output.uv TRANSFORM_TEX(input.uv, _MainTex); return output; } half4 ShadowPassFragment(Varyings input) : SV_TARGET { fixed4 tex tex2D(_MainTex, input.uv); clip(tex.a - _Cutoff); return 0; } ENDHLSL }URP的ShadowCaster会用到_LightDirection这个变量它来自Shadow库不需要自己在Properties里声明。ApplyShadowBias的作用相当于内置管线里Normal Bias那一步会对裁剪空间坐标做偏移。ColorMask 0的意思是Shadow Pass里不输出任何颜色只写深度这和内置管线里frag返回0是同一个思路。5.2 URP里接收阴影要自己组装ShadowCoordURP里接收阴影和内置管线最大的不同是不再有SHADOW_COORDS这类宏你得自己声明阴影坐标并手动传给光照函数。以URP的一个自定义Unlit Shader为例接收阴影的套路是这样的#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _SHADOWS_SOFT #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; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; float2 uv : TEXCOORD2; }; Varyings vert(Attributes input) { Varyings output; output.positionWS TransformObjectToWorld(input.positionOS.xyz); output.positionCS TransformWorldToHClip(output.positionWS); output.normalWS TransformObjectToWorldNormal(input.normalOS); output.uv input.uv; return output; } half4 frag(Varyings input) : SV_Target { float4 shadowCoord TransformWorldToShadowCoord(input.positionWS); Light mainLight GetMainLight(shadowCoord); float shadow mainLight.shadowAttenuation; half4 color tex2D(_MainTex, input.uv); color.rgb * shadow; return color; }URP里如果你用的是标准Lit Shader阴影接收不用你操心官方已经处理好了。但如果你写的是一个高度定制的Shader就需要手动把TransformWorldToShadowCoord和GetMainLight接起来。需要注意_MAIN_LIGHT_SHADOWS和_MAIN_LIGHT_SHADOWS_CASCADE这两组multi_compile必须写上否则当URP开启级联阴影时你的Shader因为没有对应变体阴影坐标计算会出错或者干脆不采样。URP不同小版本API有差异比如早期版本里ApplyShadowBias可能叫UnityApplyLinearShadowBias发现编译报错时直接去Packages缓存里翻对应版本的源码照着官方Lit Shader的ShadowCaster Pass改是最稳的。6. 实战中容易踩的阴影坑与调试方法原理讲完代码给出最后分享几个我在实际项目中反复踩过的坑。每一个都真实消耗过我的时间写出来帮大家少走弯路。6.1 Mesh包围盒把阴影剪掉了这是顶点动画类Shader最容易踩的隐形坑。你给草写了顶点弯曲主Pass阴影看着也正常但只要草离镜头远一点或者风向导致摆动幅度突然变大整棵草突然消失地面阴影也跟着没了。原因在于Unity在渲染前会先用Mesh Renderer的包围盒做视锥剔除。Shader里对顶点的偏移是发生在GPU端的Unity CPU端完全不知道顶点位移到了哪里它只拿原始网格的包围盒判断这个物体是否在视野内。一旦顶点偏移幅度超过原始包围盒范围CPU认为物体不可见直接跳过渲染阴影自然也一起消失。解决办法是在脚本里手动扩展包围盒MeshRenderer mr GetComponentMeshRenderer(); Bounds bounds mr.bounds; bounds.Expand(expandSize); mr.bounds bounds;expandSize至少取值要覆盖顶点最大位移幅度最好留一定余量。这个操作要在Awake或Start里执行并且如果顶点动画幅度极大还要考虑每帧更新或者用足够大的固定包围盒。否则你会看到远处物体的阴影时有时无非常迷惑。6.2 阴影粉刺和Bias参数的搭配画面表面出现密密麻麻的黑色闪烁小点这就是Shadow Acne阴影粉刺。原因是Shadow Map记录的是离散的深度值当表面几乎平行于光源方向时相邻像素的采样深度和自己投影到光源空间的深度差距极小深度比较的误差就被放大了造成自己误判挡住自己。解决办法有三板斧第一在Light组件上适当调大Bias和Normal Bias值。Bias是深度偏移让阴影判断放宽一点Normal Bias是法线偏移让采样坐标往法线方向挪一点。第二增加Shadow Map的分辨率。Project Settings里可以调Shadow Resolution分辨率越高深度值越精细粉刺就越不明显。第三在ShadowCaster里用好Normal Offset。内置管线中TRANSFER_SHADOW_CASTER_NORMALOFFSET已经帮你做了这件事URP里的ApplyShadowBias同样内置了normal bias。不要为了追求代码简化把这步省掉。不过Bias也不是越大越好。Bias太大会出现Peter Panning效果是物体的阴影和物体本身明显分离看起来像飘在空中。这个平衡没有标准答案只能根据场景里灯光的角度和物体尺寸实测。6.3 Frame Debugger判断阴影到底画没画如果阴影还是出不来不要再瞎猜了直接打开Frame Debugger看绘制事件。路径是Window - Analysis - Frame Debugger。开启后可以看到Unity每一帧渲染的所有事件。翻到ShadowMap相关的DrawCall比如RenderShadowMap或Shadow Depth点进去查看里面绘制的Mesh列表。如果你的物体出现在列表里说明Caster侧没问题如果找不到问题大概率在Shader的ShadowCaster Pass上。如果Caster侧正常但最终画面还是没阴影再去检查接收端的Pass代码以及Renderer的Receive Shadows开关。这个方法比我肉眼排查快得多是判断阴影链路故障的黄金手段。6.4 透明物体和双面阴影的特殊处理半透明材质在阴影上是一个老大难问题。Shadow Map只存深度不存颜色它没办法像颜色渲染那样做真正的半透明混合。所以Transparent类型的Shader投出的阴影默认是一个不透明的实心形状。如果你让一个玻璃杯投阴影地面上的影子往往是全黑的不透明。合理的变通方案有三种第一种对镂空效果使用clip而不是真正的透明混合树叶、铁丝网、栅栏都走这个路线阴影效果很自然。第二种如果物体本质上不透明但形状单薄比如一面墙壁的片面模型记得把Mesh Renderer的Cast Shadows设为Two Sided。这样Unity会专门对背面做一次ShadowCaster渲染让单面模型也能投出完整阴影避免出现只投一半的怪影。第三种如果实在需要半透明阴影就得靠自定义Projector或者另外一张低透明度贴图做装饰这已经属于假阴影范畴不属于ShadowCaster Pass本身的能力。双面阴影还有个隐藏性能点开启Two Sided后Shadow Map渲染量会翻倍因为要额外画背面。移动端上如果场景里大量使用这个选项建议评估一下GPU开销。这一篇围绕shadowCast把产生阴影和接收阴影的地基打扎实了。下一篇我打算接着写阴影在视觉表现上的进阶比如自定义软阴影、Cascade级联阴影的调参、以及移动端阴影性能优化的思路。这些东西如果开头没打好底子后面越调越懵所以基础部分值得花点时间捋清楚。