Unity内置阴影源码拆解:从ShadowMap生成到PCF采样与偏移

发布时间:2026/10/14 21:08:17
Unity内置阴影源码拆解:从ShadowMap生成到PCF采样与偏移 1. 内置阴影源码到底藏在哪儿为什么非啃不可平时用Unity勾上灯光选项里的Shadows再点一下Directional Light的Cast Shadows阴影就来了简单到让人产生一种“阴影是免费送的”错觉。可一旦开始自己写Shader——不管是角色材质、地面特效还是风格化渲染——十有八九会撞上一堵墙默认物体上的阴影一切正常自己的材质上一片惨白或者影子变成一格一格的硬边。这时候才想起来补“Unity内置阴影源码”的课然后打开文件没几分钟就退出来了——满屏宏定义、ifdef、平台判断根本读不进去。我想先说一个结论内置阴影的实现思路极其朴素真正复杂的是为了兼容各平台、各光源类型、各阴影质量档位而堆出来的几十层分支。把源码剥开之后你会发现整条链路无非是三件事先用光源视角把场景深度渲染进一张阴影纹理再把主相机看到的片元坐标换算到这张纹理上最后把两组深度拿来比较大小比较结果就是阴影衰减。读完本篇你能做到三件事第一任何阴影异常都能判断出问题出在生成、变换、采样哪个阶段第二能自己写一套阴影采样逻辑而不被内置宏绑架第三看到Profiler里的阴影开销时知道该去拧哪个旋钮。这一系列的第1篇讲的是阴影映射的整体框架比如“光源当成相机”“深度图比较”这些基础概念这里不再重复。本篇直接进入源码拆解建议你打开Unity安装目录下的Editor/Data/CGIncludes把UnityShadowLibrary.cginc和AutoLight.cginc放旁边边看边对。需要提醒的是不同Unity版本里函数名和宏组织会有些出入本文依据的是内置渲染管线某个LTS版本的写法但核心链路不会变请你带着“看思路不看死代码”的心态来读。1.1 三个阶段生成、变换、采样把阴影链路拆成三个阶段源码会瞬间变得可读。生成阶段场景物体额外走一遍ShadowCaster Pass从光源视角渲染深度写入阴影纹理。方向光和聚光灯用的是2D纹理_ShadowMapTexture点光源用的是立方体纹理。这一步决定阴影“画质”的是偏移Bias对应设置面板里的Shadow Bias和Normal Bias。变换阶段主相机渲染时在顶点阶段或片元阶段把世界坐标换算成阴影纹理的采样坐标。完成这件事的是TRANSFER_SHADOW、SHADOW_COORDS这类宏。采样阶段片元阶段用换算好的坐标去采样阴影纹理把两个深度值做比较输出一个0到1之间的衰减值乘到光照结果上。核心函数是UnitySampleShadowmap和它内部的SampleShadowmap。我见过不少人排查阴影问题时全程盯着光照参数改来改去却从来没想过“阴影图到底对不对”。这就是没把这个三阶段模型建立起来的表现。后面每一章都会围绕一个阶段展开坐标变换对应第二阶段PCF和采样对应第三阶段而偏移最反直觉——它其实发生在第一阶段的顶点处理里不在采样端。1.2 源码地图几个文件分别管什么事内置阴影代码主要分布在三个文件在CGIncludes目录下都能找到文件主要职责关键内容UnityCG.cginc基础工具集合ShadowCaster用到的裁剪空间偏移函数、投影变换AutoLight.cginc宏封装层SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATIONUnityShadowLibrary.cginc核心采样库SampleShadowmap、PCF采样、Cascade索引计算还有一些变量定义在UnityShaderVariables.cginc里比如unity_World2Shadow矩阵数组、_LightShadowData、unity_ShadowFadeCenterAndType。我们自己写的Shader只要#include AutoLight.cginc就能直接在片段里用SHADOW_ATTENUATION就是因为这条include把采样库、变量声明、平台适配全部拉进来了。读源码有个技巧先手动把ifdef全部剥掉。比如你只关心“方向光 Soft Shadow 四级Cascade”这一条路径就把其他宏分支全部忽略只顺着这条线往下读你会发现核心逻辑很短。下面两章我就按这个方式把它还原成能看懂的代码。2. 阴影坐标是怎么算出来的TRANSFER_SHADOW的完整推演先明确一个概念所谓“阴影坐标”就是一组能用来从阴影纹理中取深度值的数据。对方向光和聚光灯来说它是投影变换后的二维UV外加一个深度值对点光源来说它是立方体纹理的采样方向。Unity里的SHADOW_COORDS(idx)负责在顶点结构里声明这个坐标TRANSFER_SHADOW负责在顶点阶段填充它。2.1 SHADOW_COORDS声明不同光源根本不是一种东西SHADOW_COORDS是个非常典型的“看条件编译脸色行事”的宏。方向光走的是普通的四分量向量float4 _ShadowCoord : TEXCOORD1。点光源则完全不同它声明的是一个用于立方体纹理的方向向量对应采样UNITY_DECLARE_SHADOWMAP声明的cube sampler。同样是“阴影坐标”两种光源的成本天差地别。方向光的_ShadowCoord在某些老版本里是顶点阶段就能算好的投影坐标但开启Cascade之后很多版本的实现里顶点阶段只是把世界坐标塞进_ShadowCoord片元阶段才真正选择用哪一级Cascade、算出最终的UV。所以你看代码时会发现同一个宏在不同条件下语义完全不同。不理解这一点你就很难解释“为什么我改了顶点结构阴影就全乱了”。2.2 TRANSFER_SHADOW里发生了什么老一代Unity版本的实现非常直白// 老版本写法示意对象空间 - 世界空间 - 光源裁剪空间 o._ShadowCoord mul(unity_World2Shadow[0], mul(unity_ObjectToWorld, v.vertex));这里的unity_World2Shadow[0]是光源视角的“观察投影矩阵”顶点从对象空间一路变换到光源的裁剪空间得到齐次坐标后xy充当阴影纹理的UVz充当和阴影图做比较的深度值。这套流程和前面说的“把光源当成相机”完全对应。到了Cascade时代事情变复杂了。平行光覆盖范围太大如果整张阴影图只覆盖一个巨大的远平面区域纹素会被摊得特别稀近处阴影全是马赛克。于是Unity把相机可见范围按距离切成最多四段每段单独渲染一张阴影图四张图拼在一张纹理的不同区域里。顶点阶段通常只把世界坐标传递下去片元阶段再用类似下面的逻辑选Cascade// 简化还原真实代码用saturate做无分支计算 half SelectCascade(float3 worldPos) { float distSqr dot(worldPos - _WorldSpaceCameraPos.xyz, worldPos - _WorldSpaceCameraPos.xyz); half index 0; if (distSqr unity_SadowCascade0.x) index 1; if (distSqr unity_SadowCascade1.x) index 2; if (distSqr unity_SadowCascade2.x) index 3; return index; }选好索引后用索引取unity_World2Shadow[index]做矩阵乘法就能拿到最终的阴影UV和深度。四个分割距离存在unity_SadowCascade0/1/2这些内置变量里它们的值来自阴影设置里的Cascade分割参数。真实源码为了性能会避免分支用的是saturate、dot一类的纯数值运算一次算出索引但思路就是上面这段。2.3 为什么要绕这么大一圈不少初学者问过能不能直接用片元的世界坐标当采样坐标答案是能采样但影子全部糊掉。因为阴影纹理上的每一格对应的是“当前Cascade覆盖范围里的一块固定大小的世界空间区域”。离相机越近的Cascade覆盖范围越小每格代表的世界尺寸越小阴影越精细离相机越远的Cascade覆盖范围越大每格越粗阴影越糊。可以把它类比成手机地图的分级缩放街区级、城市级、省级、国家级各有一张精度不同的底图你人在哪就自动切换到哪一张。Cascade就是这个逻辑的3D版。理解了“四宫格”模型后面所有关于Cascade边缘接缝、远处阴影发虚、Cascade距离该怎么调的问题都会迎刃而解。3. 采样函数内部SampleShadowmap的自带PCF有多重口味坐标算好了片元里要做的就是“比较”。UnityShadowLibrary.cginc里的入口函数是UnitySampleShadowmap真正的比较逻辑都在它调用的SampleShadowmap里。这层函数有大量平台分支和阴影质量分支拆开看其实就几个“口味”。3.1 硬阴影一次比较就出结果最简单的路径伪代码如下// 示意代码未包含DX反向Z等平台翻转 float shadowMapDepth tex2D(_ShadowMapTexture, shadowCoord.xy).r; float currentDepth shadowCoord.z; fixed atten (currentDepth shadowMapDepth) ? 1.0 : 0.0;片元当前深度比阴影图里记录的同位置深度更小或相等说明它更靠近光源没有被挡住受光否则处于阴影中。这里有个隐藏的平台差异DirectX系和一些现代平台使用反向Z深度近处是1、远处是0比较方向必须反过来。这就是源码里大量#ifdef的由来之一。你在自定义阴影采样时如果不处理这个翻转同一份代码在OpenGL和DX上会出现完全相反的阴影结果。3.2 软阴影PCF到底在干什么阴影质量选Soft时采样不再只做一次比较而是对当前采样点周围一个局部区域做多次深度比较并平均。5x5的PCF字面上就是25个偏移位置各采样一次各得到一个0或1的比较结果加起来除以25。这样阴影边缘就会有一段渐变的半影区看起来是软的。内置实现大约是这种展开// 示意PCF的多次偏移采样 fixed accum 0; accum SampleShadowmap(coord offset.xy * 0.5); accum SampleShadowmap(coord - offset.xy * 0.5); accum SampleShadowmap(coord offset.zw * 0.5); accum SampleShadowmap(coord - offset.zw * 0.5); return accum * 0.25;注意这里还有个优化内核配合GPU的双线性过滤相邻的深度比较部分能被硬件一次性吃掉所以实际纹理采样次数不一定等于字面写的25次。这也是为什么内置软阴影在桌面平台上观感不错、性能也还算能接受。但移动端就完全是另一回事了——不少移动GPU不支持原生深度比较和过滤指令软件PCF的每个偏移都是实打实的全纹理采样5x5就是25次采样帧率杀手就在这里。3.3 原生PCF与软件PCF的分歧SampleShadowmap开头通常会判断SHADOWS_NATIVE或UNITY_USE_NATIVE_SHADOW_COMPARISON这类宏。如果硬件支持原生阴影比较就直接调用SampleCmpLevelZero让硬件一次完成“深度比较 双线性过滤”。这个路径又快又省指令但需要特定的纹理格式配合通常要求支持D24X8之类的深度格式并能绑定为比较采样器。我之前见过一个很典型的反面案例有同学自己写阴影采样手动tex2D取阴影纹理深度然后自己在Shader里比大小。这种做法在桌面平台上完全不会触发硬件的SampleCmp路径PCF的质量和性能都比内置差一大截。所以如果你要自定义阴影先看看内置是怎么判断平台能力的能走原生比较就尽量走。另外很多人忽略的是UNITY_LIGHT_ATTENUATION这个宏把“光源距离衰减”和“阴影衰减”打包在一起了。阴影部分采样后还要乘上_LightShadowData里存的阴影强度——也就是Inspector里Light组件Shadow Strength的真正作用点。自定义采样时别忘了把这层也接上否则调阴影强度没反应。4. 阴影偏移的真相真正动手脚的是ShadowCaster顶点阶段很多人以为Shadow Bias是在采样阶段“比较深度时网开一面”的操作这其实是个普遍误解。Unity里偏移的真正实施位置在ShadowCaster Pass的顶点阶段它会把物体顶点沿光照方向、沿法线方向各推一点点让阴影图里记录的深度整体变浅给后续比较留出余量。为什么要这么做因为阴影图分辨率有限每个纹素覆盖世界空间里的一块矩形深度也经过量化。平直的表面上片元深度和阴影图深度像两把梳子齿互相咬合于是出现颗粒状黑点即阴影痤疮Shadow Acne。只要把记录的深度整体往光源方向挪一点比较时就有缓冲痤疮自然消失。4.1 Depth Bias和Normal Bias分别是什么光源组件上的Shadow Bias与Normal Bias对应的实现动作完全不同。Depth Bias沿光照方向把顶点往光源推。记录的深度变小片元更容易通过“我比你记录的深度更近”的检验。代价是推多了会Peter Panning——物体看起来飘离接触面影子悬空。Normal Bias沿顶点法线方向把表面轻微膨胀。它在处理掠射角时特别有用——当光照方向几乎平行于表面时单纯的深度偏移几乎失效法线偏移能兜住。内置代码里这两个值会被打包进一个向量不同版本名字不同常见的是unity_LightShadowBiasx分量是深度偏移z分量是法线偏移。而真正执行偏移的函数是ShadowCaster顶点阶段里会调到的UnityClipSpaceShadowCasterPos和UnityApplyLinearShadowBias。4.2 裁剪空间里偏移的“奇怪姿势”这是内置源码最反直觉的一段它不是简单地改一下顶点z值而是在裁剪空间里把xy也推了一把。简化还原大致是这样// 伪代码示意版本不同有细节差异 float4 wPos mul(unity_ObjectToWorld, vertex); float3 wNormal UnityObjectToWorldNormal(normal); float3 wLightDir normalize(UnityWorldSpaceLightDir(wPos.xyz)); // 法线偏移 float3 normalOffset wNormal * unity_LightShadowBias.z; wPos.xy normalOffset.xy; wPos.z - normalOffset.z; float4 clipPos UnityWorldToClipPos(wPos); // 深度偏移 if (unity_LightShadowBias.x ! 0.0) { clipPos.xy wLightDir.xy * (unity_LightShadowBias.x * clipPos.w); clipPos.z - wLightDir.z * (unity_LightShadowBias.x * clipPos.w); } return clipPos;为什么要动xy因为偏移量需要发生在“光源视角下”而裁剪空间的z在透视除法和深度量化之后并不完全等价于世界空间里沿光照方向推。同时动xy再看z才能让偏移真正沿着光线方向发生而不是单纯朝相机方向拉近。你要是自己从头写一个ShadowCaster很容易忽略这一步结果就是偏移参数怎么调都没效果。另外UnityApplyLinearShadowBias负责在部分平台和屏幕空间阴影路径下直接给clipPos.z加一个线性偏移量。这些函数叠加起来就是你在面板上看到的那两个滑动条。说实话这段代码我看第一遍时也觉得绕但绕的背后是真实的坑偏移方向错了、偏移量被透视除法稀释了都会导致参数“失灵”。4.3 调参经验一张表帮你看症状抓药结合我自己的调试经验给一张快速对照表现象原因优先调整平地上出现密集颗粒黑点深度偏移不足增大Shadow Bias斜面、墙角仍有痤疮法线偏移不足增大Normal Bias物体飘离地面、影子悬空深度偏移过大减小Shadow Bias阴影轮廓变粗、棱边变圆法线偏移过大减小Normal Bias还有一个容易踩的坑方向光、聚光灯、点光源的偏移系数不是同一套数值。改完平行光觉得效果对了切到点光源可能还是满屏痤疮因为不同光源类型走的偏移函数分支不同需要的数值范围也不同。调参时不要跨光源类型抄数值。5. 只读源码没意思这些知识如何用于自定义阴影啃源码最大的收获不是“看懂”而是“能改写”。这一章把我实际项目中用到的几个改法列出来。5.1 手写一个最小阴影接收Shader当你需要完全控制阴影表现——比如卡通二值阴影、阴影渐隐动画、阴影边缘自发光——可以不依赖SHADOW_ATTENUATION宏自己在片元里采样。这里踩过坑专门提醒两步Shader里要有#pragma multi_compile_shadowcaster或者直接UsePass内置的ShadowCaster Pass否则物体不会被画进阴影图同时要includeAutoLight.cginc否则连unity_World2Shadow都拿不到。// 顶点输出声明 float4 _ShadowCoord : TEXCOORD1; // 顶点阶段方向光单级阴影 o._ShadowCoord mul(unity_World2Shadow[0], float4(worldPos, 1)); // 片元阶段自己采样做二值化 float shadowMapDepth tex2D(_ShadowMapTexture, i._ShadowCoord.xy).r; float currentDepth i._ShadowCoord.z; fixed rawShadow (currentDepth shadowMapDepth) ? 1.0 : 0.0; fixed shadow step(0.5, rawShadow); // 二值硬阴影如果你用的是开Cascade的方向光这里千万不能直接取unity_World2Shadow[0]而要用UnityComputeShadowCoords这类函数按距离选Cascade。我第一次写自定义阴影时偷懒没处理Cascade结果远处片元永远采样cascade0画面远处阴影全部花掉排查了半天才反应过来。另外别忘了内置衰减里还乘了_LightShadowData的阴影强度自定义采样时也要把这一层接上否则面板上的Shadow Strength会失效。5.2 性能定位阴影采样到底贵不贵判断阴影是不是性能瓶颈别凭感觉。先在Frame Debugger里查看阴影图渲染时有多少Draw Call再在主相机渲染事件里看Shader的纹理采样次数。如果Profiler里纹理采样开销明显偏高最值钱的旋钮通常有两个一是把Quality设置里的Soft Shadows降级成HardPCF从几十次采样变成单次比较省得立竿见影二是调Shadow Distance这个参数不只是“多远画阴影”它直接决定Cascade覆盖范围和阴影图利用率。Shadow Distance调小近景Cascade能分到更多纹素近处阴影会变清晰代价是远处完全没影子。很多“远处阴影糊成一团”的抱怨其实不是阴影图分辨率不够而是Shadow Distance设太大、Cascade没有分配好导致的。理解这三者的关系比盲目把阴影图分辨率拉到4096有用得多。5.3 为什么我仍建议保留内置方案读源码时你会看到一堆平台适配DX的反向Z、Metal的深度语义、主机平台的原生PCF、移动端没有深度纹理时的回退方案……这些不是Unity故意写复杂而是图形API的世界里就是这样到处是坑。自己从零造阴影系统最容易翻车的不是算法本身而是平台差异。所以我的实践原则是默认用内置阴影需要风格化时在采样层做包装不要轻易替换生成层。生成层的坑最多、收益最小改动频繁的话很快会被平台问题淹没。6. 阴影Bug排查实操三个案例和一套流程最后分享几个我真实调过的案例都是这一套“三阶段”模型的直接应用。6.1 排查流程先看阴影图再看坐标最后看比较遇到任何阴影问题第一步永远是确认“阴影图本身对不对”。在Frame Debugger里找到阴影贴图的渲染事件直接查看_ShadowMapTexture的内容能看到清晰的深度轮廓说明生成层没问题一片空白说明ShadowCaster Pass没执行一团黑说明深度没写进去。阴影图没问题之后再看坐标变换最后才怀疑比较逻辑。按这个顺序来大部分问题能在三步内定位不要上来就乱调Bias。6.2 案例一角色脚下阴影抖动现象角色一走动脚下阴影像水波一样闪。从Frame Debugger看阴影图一切正常问题出在坐标比较阶段——实际是Normal Bias为0。角色脚底与地面夹角大光照几乎是掠射角深度偏移在这种角度下失效于是大面积痤疮。把Normal Bias从0调到0.05后立刻消失。这个现象在低分辨率阴影图或超大范围阴影下特别明显因为纹素覆盖的世界空间太大等深线的“锯齿”被放大成可见的闪烁。6.3 案例二远处阴影大范围消失现象近处阴影正常远处影子完全不见。当时第一反应是Shadow Distance太小查了发现其实设得很大问题出在Cascade切分上最近一级Cascade覆盖范围太小远处片元落到了cascade选择逻辑之外直接判定为无阴影。解法是在阴影设置里把Cascade的分割距离向远处拉开让四级Cascade覆盖整个可见范围。顺带一提Cascade边界上的硬切缝是另一个经典坑。内置的UnityApplyShadowFade就是专门处理这个的——它利用unity_ShadowFadeCenterAndType算出的衰减因子在Cascade边界附近把阴影渐隐过渡。如果自定义采样时把这层跳过了远处就会频繁出现一条明显的“影子边界”。6.4 案例三移动端软阴影导致帧率下降现象某款中端机型上开启Soft Shadow后帧率掉5到8帧。Profiler对比后发现每帧在SampleShadowmap系列函数上的采样次数高得吓人5x5软件PCF是主要元凶。把阴影质量从Soft改成Hard之后帧率恢复代价是阴影边缘略硬。后来配合把阴影图分辨率提到2048观感基本无差省下来的填充率拿去给角色特效了。这个案例说明理解PCF的采样代价能帮你在优化时直接找到最值钱的开关。6.5 最后再说两句如果你正在被内置阴影折磨不要急着写自定义ShadowMap。先把三阶段模型过一遍——生成层看Bias变换层看Cascade采样层看PCF——大多数问题都能在十分钟内定位。读源码这件事第一次确实难但当你有一天能随手改掉采样函数、做出自己想要的那种阴影风格时前面那些趴在CGIncludes里的时间就全值回来了。我个人现在写任何自定义Shader都会先想清楚它在这三阶段里的位置再决定要不要动内置的东西。这个习惯建议你也试试。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询