Unity Shader里的阴影,对很多刚上手自定义渲染的开发者来说像一层窗户纸。明明光源参数没动,编辑器里的阴影也开着,模型就是不投影子;或者影子投了,但一到自研的Unlit材质上,就变成一片渐变灰,既不黑,也不跟随光影变化。这篇阴影系列第二篇,不做概念科普,直接讲Unity内置阴影的源码链路——从ShadowCaster Pass怎么画阴影贴图,到接收方怎么采样_ShadowMapTexture,再到屏幕空间阴影在哪一层介入。目标是让看完的人能动手给自己的Shader补上阴影接收和投射,也能在面对阴影异常时快速定位到具体环节。
系列第一篇聊了阴影的基础配置和编辑器参数。这篇会更往前一步,走进代码层。涉及的文件主要是UnityCG.cginc、AutoLight.cginc和UnityShadowLibrary.cginc,适合已经能独立写基础Shader、但每次碰到阴影相关宏都要靠复制的开发者。看完之后你至少能做到三件事:给自己的Shader手动加上阴影接收;看懂内置阴影相关报错和警告;遇到阴影异常时能判断是贴图问题、坐标问题还是偏移问题。
1. 阴影是两段管线:先搞清楚谁在产生,谁在消费
1.1 一句话版管线
实时阴影的本质是可见性判断。从任何一个片元的角度看,要决定"我是否处在阴影里",就是在回答一个几何问题:从光源位置看向这个片元,中间有没有别的物体挡着。为了高效回答这个问题,业内通用的做法是先站在光源位置把场景渲染一遍,把每个可见表面的深度记录下来,生成一张Shadow Map;等到真正渲染物体时,再把当前片元换算到光源视角下的坐标,和Shadow Map里记录的深度做比较,从而得到0到1的阴影衰减。
这套机制在Shader层面被拆成了两个完全独立的阶段:
第一个阶段叫阴影贴图生成,对应物体Shader里的ShadowCasterPass。这个Pass不输出最终颜色,它只负责把物体的深度写到阴影贴图里。第二个阶段叫阴影接收,对应物体Shader里的前向渲染Pass。物体在计算光照时从阴影纹理里采样,得到衰减值,再乘到最终颜色上。
理解这两个阶段的关键在于一句话:阴影贴图生成和阴影接收用到的Shader代码,不是引擎某个全局开关自动注入的,而是写在每一个物体自己的Shader里。内置Shader早就帮你写好了,所以看上去像是个开关;一旦你写自定义Shader,就轮到你自己补全这两段代码了。
1.2 内置阴影涉及的关键文件
平时写Shader时,我们用的很多宏最终都指向内置cginc文件。阴影这条链路主要涉及三个文件:
UnityCG.cginc:提供最基础的阴影坐标结构体、ShadowCaster顶点辅助函数和常见的坐标变换函数。AutoLight.cginc:提供光照衰减和阴影接收的集成宏,比如SHADOW_ATTENUATION。UnityShadowLibrary.cginc:提供真正的阴影贴图采样函数,比如UnitySampleShadowmap。
内置管线的Forward路径里,这些文件按固定顺序被包含进Shader代码。我们通常不需要手动include,但需要知道符号来自哪里。排查编译错误时尤其有用——你发现某个宏只有在定义某个关键字后才展开,就得回到这些文件里查条件编译分支。
不建议把代码背下来,但有个习惯值得养成:在任何Unity工程里,选中cginc引用行,右键跳转到内置着色器文件夹,直接打开源文件。这篇涉及的所有宏,都能在安装目录的CGIncludes文件夹下找到。以实际工程为基准去读,比自己脑补准确得多。
2. 阴影贴图是谁画的:ShadowCaster Pass源码拆解
2.1 顶点阶段:把顶点塞进"灯光相机"的裁剪空间
每个会投射阴影的Shader,都需要一个Tag为LightMode=ShadowCaster的Pass。这在很多内置Shader里是隐式存在的,但不代表自定义Shader可以省略。
这个Pass的顶点着色器核心任务只有一个:把顶点从模型空间变换到光源空间的裁剪坐标。表面上代码很短,可能就一行UnityObjectToClipPos,但它背后有几个关键点容易误解。
第一个误解是"UnityObjectToClipPos到底把顶点转到谁的裁剪空间"。在ShadowCaster Pass执行的时候,渲染目标其实已经切到了阴影贴图,引擎把当前的视图投影矩阵换成了光源视角下的那个。所以这行函数的输出,不是主相机看到的裁剪坐标,而是"灯光相机"看到的裁剪坐标。这也是为什么ShadowCaster Pass通常不关心主相机在哪、主相机方向如何。
第二个关键是法线方向的处理。阴影贴图天生有精度问题:如果物体表面的深度刚好和目标表面重合,深度比较会受浮点误差影响,出现黑白闪烁。Unity的做法是在ShadowCaster顶点阶段把顶点沿法线方向向外偏移一点点,让表面深度和它自身稍微脱开。这个偏移并不是接收阴影时才修的,而是发生在写阴影贴图的这一步。光源面板上的Bias和Normal Bias参数,最终影响的就是这里。
实际工程里,UnityCG提供了一个辅助函数,逻辑大致是:
float4 UnityClipSpaceShadowCasterPos(float4 vertex, float3 normal) { float4 wPos = mul(unity_ObjectToWorld, vertex); float4 wNormal = normalize(mul((float3x3)unity_ObjectToWorld, normal)); // 这里沿法线偏移顶点,抵消阴影贴图的表面自遮挡 wPos.xyz += wNormal * _ShadowBias; return mul(UNITY_MATRIX_VP, wPos); }注意这里的_ShadowBias并不是Light组件面板上那个Bias参数的直接值,引擎会把多个偏移量组合起来,再通过UnityApplyLinearShadowBias做线性空间的深度偏移。每个版本的具体实现有差异,但思路是一致的:先在世界空间沿法线推顶点,再在裁剪空间做深度偏移。
2.2 片段阶段:输出深度与Alpha Test的坑
ShadowCaster的片段着色器非常简单,很多情况下只需要保证深度正确写入,颜色值是什么并不重要。在OpenGL类平台上,深度值由光栅化阶段自动生成,片段函数甚至可以什么都不写;但在D3D类平台上,需要输出SV_Depth或者返回一个占位颜色。
真正的重点不是"输出深度",而是支持镂空阴影。如果你的材质用了AlphaTest或者片元裁剪,阴影也必须同步裁剪,否则树冠和草丛在地面上的阴影就是一个个完整的矩形。内置的ShadowCaster片段宏做了这件事,它会保留和执行主Pass里同样的裁剪逻辑。
这里是一个高频翻车点:很多开发者图省事,让ShadowCaster的片段着色器直接返回固定值,结果带Alpha纹理的模型在地面上的阴影完全不镂空,形状像一块板子。解决方式是在ShadowCaster Pass里手动把纹理采样和裁剪做一遍:
fixed4 fragShadowCaster(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); clip(col.a - _Cutoff); return 0; }这样阴影贴图里就会保留镂空信息,地面上的阴影才会出现和模型一致的形状。
2.3 没有ShadowCaster Pass会发生什么
很多人遇到"模型不投影"的第一反应是检查Light组件的Shadow Type,但真正原因往往是Shader缺少ShadowCaster Pass。
只写了ForwardBase和ForwardAdd的自定义Shader,投射阴影和接收阴影都会失效。投影视效是因为ShadowCaster Pass不存在,物体根本没有被画进阴影贴图。接收失效则更隐蔽:接收阴影的宏依赖一系列内置变量,这些变量只有在Pass的LightMode标记正确时才会被赋值,但赋值动作是引擎隐式完成的,不会在Shader里体现出来。
更隐蔽的情况是Pass存在但Tag写错,比如不小心把LightMode=ShadowCaster写成了ShadowCaster2,引擎会直接忽略整个Pass,而且很多情况下不报错,只会让你对着一个不投阴影的模型发呆。所以排查自定义Shader阴影问题时,我建议第一件事永远是:检查这个Shader的Pass列表里有没有一个Tag为ShadowCaster的Pass,以及它的顶点/片段函数是否真的编译通过。
3. 接收阴影的代码路径:宏三件套到底做了什么
3.1 SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATION
前向渲染里接收阴影,绝大多数自定义Shader都会用到三个宏。它们在不同Unity版本里名字略有差异,但功能是稳定的:
SHADOW_COORDS(n):在顶点到片元的插值结构体里声明一个阴影坐标变量,通常占用一个TEXCOORD槽位。TRANSFER_SHADOW(o):在顶点着色器里计算这个坐标,并传递到片元。SHADOW_ATTENUATION(i):在片元着色器里采样阴影贴图,得到一个0到1的衰减值。
很多新手第一次看到这三个宏时,会觉得"这不是魔法吗"。确实像魔法,但魔法背后是条件编译。展开之后你会发现,SHADOW_COORDS(1)在某些平台定义为一个float4变量,在某些平台定义为两个变量,在需要Shadow Mask时又是另一套结构。这也是为什么Unity官方不建议大家手写这部分——宏会自动适配平台和渲染路径。
用这些宏时,有一个容易忽略的依赖:Pass的开头需要声明#pragma multi_compile_fwdbase或者#pragma multi_compile_fwdadd。这个编译指令负责生成近似的关键字变体,比如SHADOWS_SCREEN、SHADOWS_CUBE。如果你不写这句,TRANSFER_SHADOW和SHADOW_ATTENUATION很可能被编译成空操作,阴影静默失效。
我在实际项目里见过最典型的情况,就是某个特效Shader复制过来时漏了multi_compile,整个场景只有这个特效不接收阴影,其他物体都正常。排查了半天,最后加上一行编译指令就好了。
3.2 从灯光视角坐标到阴影贴图的UV:采样细节
采样Shadow Map时最核心的步骤是坐标变换。光源把场景渲染成深度贴图时,每个像素对应一个光源空间的裁剪坐标;物体片元在接收阴影时,要把自己在光源空间的坐标转换成这个贴图的UV和深度,才能做比较。
Unity对应的采样函数在UnityShadowLibrary.cginc里,以UnitySampleShadowmap为代表。这个函数内部会做几件事:
- 把光源空间坐标从
[-1,1]范围映射到[0,1]的纹理坐标。 - 用纹理坐标采样阴影贴图,得到该位置记录的光源深度。
- 把片元自身到光源的深度和采样得到的深度做比较,得出阴影值。
这些步骤听起来简单,但实际代码里有大量针对平台差异、纹理格式差异、不同光源类型的分支。点光源阴影用的是Cubemap,平行光用2D贴图,聚光灯也是2D贴图但坐标计算方式不同,而Shadow Mask模式又要额外混合光照探针的遮挡信息。所以当你想真正深入时,不要只盯着一个函数看,要看它的所有条件编译分支。
3.3 bias和normalBias到底在抵抗什么
阴影贴图的分辨率不可能无限高。在贴图像素边缘,多个深度值共享一个纹素,采样时会出现深度比较的随机误差,产生两种典型瑕疵:
- 阴影粉刺:表面本身不该有阴影,但因为贴图精度问题,深度比较结果错误,表面出现密密麻麻的黑色小点。
- 阴影漏光:该有阴影的地方因为比较失败而没阴影,常见于多个物体非常靠近时。
阴影贴图精度天然是有限的,所以深度比较必须带偏移。bias的作用是把片元往光源方向推一点,让深度比较结果更倾向于"不被遮挡";normalBias的作用是沿法线方向推开片元,规避表面自遮挡。Light组件上的Bias和Normal Bias两个参数,分别对应这两类偏移的加权系数。
这里的坑在于,偏移太大会导致阴影整体偏离物体,产生"阴影悬浮"。调阴影时,我坚持一个方法:先把Normal Bias归零观察,确认没有粉刺后再一点点加回去。这样可以避免把漏光或悬浮误判成别的问题。调到参数合适时,阴影会贴着物体边缘,既不渗漏也不碎裂。
4. 屏幕空间阴影:和Shadow Map不一样的接收方式
4.1 屏幕空间阴影在哪一层介入
内置管线的Quality设置里有一个"Shadow Projection"和"Screen Space Shadows"选项。开启Screen Space Shadows后,Unity会先生成一张屏幕空间的阴影遮罩图,然后在所有接收阴影的物体上采样这张图,而不是直接对每个物体重复执行Shadow Map坐标变换和深度比较。
这样做的好处是性能更友好。由于屏幕空间阴影是在屏幕后处理阶段一次性生成的,每个物体只需采样一次遮罩图,省掉了大量重复的坐标变换。代价也很明显:屏幕空间阴影只对不透明物体有效。半透明物体在生成遮罩图时通常不参与深度写入,因此自研半透明Shader想接收阴影时,往往只能采样Shadow Map,直接采样屏幕空间遮罩图会得到不正确的阴影形状。
从源码符号看,开启屏幕空间阴影后,Shader里会出现_ScreenSpaceShadowmapTexture这个采样器。SHADOW_ATTENUATION会根据关键字SHADOWS_SCREEN决定走Shadow Map采样还是走屏幕空间采样。所以内置Shader里很多宏并没有一行固定的实现,而是按条件编译切换的。
4.2 内置管线和URP在阴影接收上的差异
很多人迁移到URP之后,发现自定义Shader的阴影宏全变了。URP里虽然也提供SHADOW_COORDS、TRANSFER_SHADOW这些接口,但底层实现和内置管线有明显差异:
- 内置管线的阴影采样函数分散在多个cginc文件,受旧的光照探针、实时GI等特性影响,条件分支很多。
- URP里Shadow Atlas和Shadow Mask的采样统一在
Shadows.hlsl,接口更收敛,关键字也更少。 - URP的Main Light Shadow和Additional Lights Shadow分开处理,一个Shader里可能要采样多张阴影贴图,坐标计算方式不同。
如果你正在往URP迁移,我的建议是不要盲目沿用内置管线的阴影宏,而是用URP自带的阴影接口。强行移植旧代码会让代码里塞满条件编译分支,维护成本极高。URP的文档和源码都比较清楚,直接看Shadows.hlsl里GetMainLightShadow和GetAdditionalLightShadow的输入输出,比自己猜要快得多。
5. 实操:给自研Shader接上阴影系统
5.1 最简接入方案与完整代码
以一个完全自定义的Unlit风格Shader为例,我们要让它具备两个能力:自己投射阴影,同时能接收其他物体投射的阴影。
投射阴影需要补一个ShadowCaster Pass;接收阴影需要在主Pass里加上三个宏和一个编译指令。完整代码如下:
Shader "Custom/UnlitShadowReceiver" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } Pass { Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include "UnityCG.cginc" #include "AutoLight.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; SHADOW_COORDS(1) }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; TRANSFER_SHADOW(o); return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; fixed shadow = SHADOW_ATTENUATION(i); return col * shadow; } ENDCG } Pass { Tags { "LightMode"="ShadowCaster" } CGPROGRAM #pragma vertex vertShadowCaster #pragma fragment fragShadowCaster #include "UnityCG.cginc" float4 vertShadowCaster(appdata_base v) : SV_POSITION { return UnityClipSpaceShadowCasterPos(v.vertex, v.normal); } fixed4 fragShadowCaster() : SV_Target { return 0; } ENDCG } } }把这段贴进Unity就能看到效果。这个Shader里,TRANSFER_SHADOW负责在顶点阶段计算阴影坐标,SHADOW_ATTENUATION在片元阶段采样并返回阴影值。如果你想让阴影边缘更柔和,可以开启光源的Soft Shadows,但注意PCF采样是引擎内置实现的,不需要额外写。
5.2 验证阴影是否生效的调试方法
判断阴影到底有没有生效,比很多人想象得难。屏幕上看到一团黑并不代表阴影系统正常,有可能是模型自身AO或者环境光缺失。
我常用的验证手法有三个:
- 把场景里的所有光源关掉,只留一个平行光,并把环境光强度调成零。这时如果模型和地面之间出现清晰的阴影边界,说明Shadow Map链路正常。
- 在主Pass里把
SHADOW_ATTENUATION(i)的结果直接赋给col.rgb输出,画面会变成灰度图——被阴影罩住的部分是黑色,亮部是白色。这样能直观判断阴影范围和渐变是否合理。 - 临时关闭屏幕空间阴影,对比开关前后差异。如果阴影形状和位置变化很大,说明问题出在屏幕空间阴影遮罩生成环节,而不是Shadow Map本身。
这三种方法不需要任何插件,直接用Frame Debugger也能看,但要回答"阴影到底生效没"这个问题,最直接的还是改Shader输出。
6. 阴影伪影速查:源码级常见坑与排查
6.1 阴影粉刺和阴影漏光的原因分析
前面说过,粉刺和漏光都源于深度比较的浮点误差。想从根上理解这两个问题,可以做一个简单实验:在Shadow Map分辨率固定的情况下,把平行光的Bias调到很大,你会发现物体表面干净了,但阴影整体偏离,出现"飘起来的影子"。这说明偏移动摇了深度比较的基准,让原本应该在同一位置的深度产生了位移。
另一个常见伪影是阴影边缘的锯齿。Shadow Map的分辨率决定了锯齿的粗细,开启软阴影可以缓解,但代价是多个采样点比较深度。UnitySampleShadowmap里,软阴影会执行PCF之类的多次采样函数,采样步长由_ShadowMapTexture_TexelSize之类的参数控制。如果在自定义代码里自己实现PCF,要注意按平台调整采样步长,否则会在移动设备上出现奇怪的条纹。
6.2 常见问题快速排查表
| 现象 | 常见原因 | 优先排查项 |
|---|---|---|
| 模型不投射阴影 | 缺少LightMode=ShadowCaster的Pass | 检查Pass列表和Tag拼写 |
| 模型不接收阴影 | 主Pass没有用阴影宏,或没加multi_compile_fwdbase | 检查ForwardBase Pass代码 |
| 阴影是完整方块 | ShadowCaster片段机制没有执行Alpha clip | 给ShadowCaster补采样和clip |
| 阴影大面积偏移 | Bias和Normal Bias设置过大 | 先归零调试,再逐步增加 |
| 阴影边缘黑点密集 | Shadow Map分辨率不足或bias过小 | 提高阴影分辨率或调节bias |
| 半透明物体阴影异常 | 半透明物体不参与屏幕空间阴影 | 换用Shadow Map采样或调整排序 |
这张表是我排查自定义Shader阴影问题时最常用的一页纸。大多数问题集中在Pass缺失和宏没写全,真正的采样算法问题反而少见。
6.3 阴影优化的几点经验
阴影优化的核心是纹理带宽。移动端尤其明显:一张全屏屏幕空间阴影图每帧要采样一次,再加上Shadow Map本身的采样,带宽压力不小。如果项目对发热和帧率敏感,建议优先考虑:
- 降低Shadow Map分辨率;
- 限制同时参与阴影的灯光数量;
- 对距离较远的物体使用独立的低分辨率Cascade。
从源码角度,阴影相关关键字在multi_compile_fwdbase里会展开很多变体。每个变体都意味着额外的编译和可能的Shader分支。自定义Shader如果只想支持主光源阴影,可以只保留必要的编译指令,避免无谓的变体膨胀。尤其是在移动端,Shader变体一多,包体和加载时间都会肉眼可见地增长。
最后说说个人体会。我在调自定义Shader的阴影时,踩得最多的大坑,其实是"看起来像阴影问题的阴影,最后发现是坐标变换错了"。建议所有刚接触阴影源码的开发者,先花一晚上时间,把UnityCG.cginc和UnityShadowLibrary.cginc里几个关键宏的展开结果打印出来看一遍。把宏展开之后,你可能会惊讶地发现,阴影接收的十行宏代码里藏了几十个分支判断。搞懂这些分支之后,再遇到阴影问题时,你就能立刻说出它是哪个环节出的错,而不用靠试。