1. 投影阴影这件事到底在解决什么问题先聊个很实际的场景你搭好一个场景灯光一打地面上干干净净像悬浮在半空一样玩家角色走过去毫无重量感。这时候你才意识到影子不是锦上添花的东西它是建立视觉接地感的关键。玩过打砖块这类小游戏的人应该都有体会球和砖块之间的碰撞手感再好如果没有投影阴影把两者之间的相对空间关系画出来总觉得球是飘着的。Unity 的投影阴影说白了就是实时阴影系统。引擎在渲染一帧画面之前会先从光源的视角生成一张深度图这张深度图记录的是“从光源看过去场景里哪些物体离光源最近”然后在渲染主摄像机的画面时把每个像素的位置变换到光源空间里和深度图里的记录做一次深度比较。如果这个像素离光源的距离比深度图里记录的距离还要远说明它被别的物体挡住了那它就处于阴影中。这就是 Shadow Map 的基本原理整套流程 Unity 帮你封装好了你要做的是理解这个机制然后按需配置、按需改写。这篇文章主要围绕投影阴影的实现来展开包括内置 Shadow Map 机制的原理拆解、Unity 阴影面板里的参数到底在干什么、自定义 Shader 里怎么接阴影、ShadowCaster 的实战写法以及几个场景里最常见的阴影问题怎么排查。适合三种人看一是刚开始在引擎里调阴影参数、觉得 Bias 怎么调都花屏的新手二是自己写 Shader 发现模型接收不到阴影、需要自己动手接阴影管线的中级开发者三是做风格化渲染、需要把默认阴影彻底改写成自己需要的形态的进阶玩家。2. 引擎里的影子是怎么“画”出来的2.1 深度图、灯光视角与阴影判定Shadow Map 的实现思路其实不复杂。想象你在房间角落放了一盏灯对着墙壁照站在灯的视角看整个房间哪些物体把光线挡住了哪些区域被物体挡住照不到就是你从灯光视角能看到的东西。在 Unity 里引擎会为参与阴影投射的光源额外设置一个虚拟相机这个相机的朝向跟光源方向一致。此时引擎关闭一切颜色计算只保留深度信息输出场景里的每个顶点写到这张图里的是从光源视角看到的深度值。这张深度图长什么样对平行光来说它是一个正交投影的深度图范围由 Shadow Settings 里的 Shadow Distance 决定对点光源则是一个六面体的 Cube Map聚光灯则是透视投影的深度图。不管哪种核心信息只有一条场景表面离光源最近的距离是多少。接着进入主摄像机渲染阶段。画面里每个片元你可以简单理解为一个像素被光栅化出来之后会被变换到光源空间坐标系中得到一个在光源视角下的深度值。拿这个深度值去深度图里去查刚才的前景深度如果这个片元的深度大于查到的最近深度说明它在被挡住的一侧那它就是阴影。这里有个很容易忽略的细节深度图的分辨率是有限的不是每个像素都能做到一比一对应。默认的阴影分辨率一般设置在 1024 或 2048 这个量级场景范围一旦拉大每个深度图纹素对应的实际场景区域就变大阴影边缘就开始出现锯齿。这就是为什么 Unity 的阴影设置里专门有一个 Shadows Solution 选项用来控制分辨率档位。2.2 为什么默认阴影有时是灰的而不是纯黑很多人第一次注意到阴影不是纯黑的时候会当成 bug其实这套设计是有意的。完全纯黑的阴影意味着没有任何环境光补进来在视觉上会显得特别“脏”而且会让暗部丢失所有细节。Unity 默认的阴影计算逻辑里阴影遮罩值和环境光照是相乘关系也就是说阴影区域仍然可以被环境光轻度照亮从而保留纹理细节。这种处理对写实风格来说是合理的因为自然界里不存在完全没有环境光的阴影。但如果你在做二次元风格或者卡渲风格就会觉得这个“灰”特别碍眼——因为平面化风格的画面里阴影应该是明确的分界线不该出现渐变的灰度过渡。这时候一般有两种做法一种是把环境光调低让阴影更重另一种是干脆用自己的 SDF 阴影或贴花阴影替代实时阴影这也是很多二次元 Shader 的做法。我在做风格化场景时更喜欢自己控制阴影的硬边程度。做法是拿到 Shadow Map 的采样结果之后先用 saturate 函数做一次阶梯量化让阴影从连续过渡变成两到三档的硬切分再配合主色调叠加出来的效果比 Unity 默认的半灰阴影干净得多。3. 阴影参数的每一个字都要拆开讲3.1 Shadow Settings 面板参数逐个看在 Quality Settings 里找到 Shadows 分组这一块决定的是全场景阴影渲染的默认行为。先说 Shadow Distance它定义的是“多远的物体才值得算阴影”。这个值不是随便填的它要根据场景尺度和性能预算来定。移动端一般建议 30 到 50 米PC 可以放宽到 100 米甚至更远。需要注意的是它有一个视觉副作用超出 Shadow Distance 的物体会突然“没影子”如果场景里有大片空地和远景这个边界会非常明显。Shadow Cascades 是平行光专用的级联阴影方案。Shadow Cascades 的核心价值在于平行光会覆盖很大范围如果用一整张 Shadow Map 覆盖全场景近处的阴影分辨率一定不够用。级联的做法是把视锥体按距离切成几段每段各分配一张完整分辨率的 Shadow Map。Unity 提供了 No Cascades、Two Cascades、Four Cascades 三档四档的效果最好但性能开销几乎翻倍。接着是 Shadow Projection或 Shadowmask Mode取决于你的渲染管线还有 Shadow Resolution 和 Shadow Bias。Shadow Resolution 有 Low、Medium、High、Ultra 四档Low 到 Ultra 大约是 512 到 2048 纹理尺寸的变化。我实测下来在 PC 上 High 和 Ultra 的区别在平常视角里并不明显真正拉开差距的是在近距离时看地面上的影子边缘。此外在 URP 里还有一个关键参数叫 Shadow Depth Bias 和 Shadow Normal Bias这两个参数是阴影质量的门面下面单独展开。3.2 Bias 和 Normal Bias阴影闪烁的源头在这用生活化类比解释 Bias 是干什么的。Shadow Map 的深度图是有分辨率的它把连续的三维深度值离散化成了一格一格的纹理像素。当你从主摄像机视角渲染一个平面时这个平面上的点在被变换到灯光空间后采样的深度值会在一格像素的边缘来回跳动导致阴影时有时无表现出来就是那种高频闪烁的小噪点学名叫 Shadow Acne。为了消掉这种因为是离散采样引起的自遮挡Unity 允许你在阴影采样的深度比较中加一个小小的偏移让被查询像素的深度值略微向光源方向拉近一点。这个偏移就是 Shadow Depth Bias。偏移值太小消不掉噪点偏移值过大则会出现 Peter Panning 现象也就是阴影整体往里收缩物体和阴影之间出现一段镂空的缝隙物体看起来像是飘在影子之上。Normal Bias 是另一条路线。它做的事情是在片元的法线方向上做一段微小的偏移把采样点沿着法线方向往外推一点点让接近垂直角度的表面减少自遮挡误判。实际经验是当 Shadow Depth Bias 已经把底部阴影调到位却依然在物体边缘看到细碎黑影时先加 Normal Bias数值大概在 0.5 左右起步。调试 Bias 有个很笨但有效的方法把光源固定住摄像机绕物体转一圈盯着物体与地面接触的那条边分别把 Depth Bias 调成 0、0.5、1、2观察噪点和漏光的切换点再取一个刚好两边都不出现的值。不要指望一个 Bias 搞定所有光源不同光源距离和角度下的表现差异很大尤其是平行光和点光源的最佳 Bias 一般是不同的。4. 自定义 Shader 怎么把阴影接进来4.1 ShadowCaster Pass 和 ShadowReceiver 的完整链路一个物体要被实时阴影系统识别它必须满足两个条件一是能把自己的深度写进 Shadow Map二是能在渲染时采样 Shadow Map 并输出阴影效果。对应到 Shader 里分别是 ShadowCaster Pass 和主 Pass 里的阴影采样逻辑。在内置渲染管线里如果你使用 Surface ShaderUnity 会自动生成 ShadowCaster Pass不太需要你管。但使用顶点片元 Shader 时引擎不会自动帮你加这是很多人自定义 Shader 后突然发现场景里所有模型都“丢了影子”的根本原因。解决方式也很直接在 Shader 里加一个Pass { Name ShadowCaster Tags { LightMode ShadowCaster } // 这里需要包含 Unity 提供的 ShadowCasterPass 相关函数或者自己写一个只输出深度的顶点片元逻辑 }而主 Pass 这边接收阴影不是免费的。你需要在 v2f 结构里声明float4 shadowCoord : TEXCOORD1;然后在顶点着色器里调用o.shadowCoord TransformWorldToShadowCoord(worldPos);片元阶段就可以用 Unity 提供的函数float shadow MainLightRealtimeShadow(TransformWorldToShadowCoord(worldPos));这个 MainLightRealtimeShadow 会自动帮你完成深度图采样、级联选择、Bias 修正等一系列操作。自己写的话要注意采样 Shadow Map 的坐标是从光源投影矩阵变换出来的不是物体的 UV方向搞错的话阴影会像被狗啃过一样扭曲。4.2 URP 里的阴影接收和内置管线有什么不一样URP 把整个阴影采样流程重新封装过函数命名和参数结构都变了。在 URP 的 Lit Shader 里阴影功能写在 ShaderGraph 里就比较容易直接拖一个 Main Light Shadow 节点即可。如果是手写 ShaderURP 下面会看到 UNITY_SHADOW_COORDS 这类宏它的启用与否受材质的 Receive Shadows 开关控制。URP 和内置管线的另一大区别是级联阴影的采样方式。内置管线的阴影坐标系我用它来比较习惯它有一套固定的投影矩阵序列。URP 在 Forward 渲染路径下默认支持四级的级联但如果你自己写阴影采样函数一定不要忘了初始化 Shadow Sampler否则默认会回落到第一级级联远处的阴影边缘会严重发虚。在 URP 下我一般这么写主 Pass 的阴影接收部分#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl half4 frag(v2f i) : SV_Target { float4 shadowCoord TransformWorldToShadowCoord(i.positionWS); half shadow MainLightRealtimeShadow(shadowCoord); half3 lighting saturate(mainLightColor * shadow ambientColor); return half4(albedo * lighting, 1); }这个写法走的是 URP 默认的阴影路径代码量少而且自动处理了 Bias、级联和多种光源类型。如果你想手动实现软阴影那你需要拿到具体的 Shadow Map 纹理并自己写 PCF 采样后面会说。4.3 从性能角度重新审视阴影相关配置性能问题永远是阴影逃不开的话题。实时阴影的开销主要在两个方面一是 Shadow Map 的渲染本身会多出几个 Draw Call每个投射阴影的物体都需要在灯光视角下再被渲染一次二是主摄像机渲染时每个片元都要做一次阴影纹理采样和深度比较这个开销随屏幕覆盖面积变化。移动端做性能优化时首先要卡的就是阴影分辨率不要无脑开 Ultra。真实项目中我在 1080p 手机上常用的方案是 Shadow Resolution 开 Medium配合 Shadow Cascade 开 Two CascadesShadow Distance 控制在 25 米以内。实测这个组合下近处阴影大致锐利远处虽然模糊但不至于闪烁整体性能损耗可以压到单个光源下 1.5 毫秒以内。如果场景里有大量的小物件比如石块、植被、绳索这一类建议在 Mesh Renderer 的 Shadow Casting Mode 里改设为 Off只让地面、墙壁等大结构投影。像素级细节的投影在移动端很难看出效果但 Draw Call 和填充率的花费是实打实的。还可以用遮挡剔除把视锥外物体的投影剔除掉这个是 Unity 自带的 Occlusion Culling 干的活确保只要相机移动阴影就会配合剔除计划动态减少投影对象数量。5. 重写 ShadowCaster 的实战玩法5.1 包体之外的“假阴影”方案实时阴影不是唯一的选择。假阴影、贴花阴影、甚至平面投影阴影在很多场景里反而是更优解。平面投影阴影的原理是把模型的顶点沿着光照方向投影到一个平面上在平面上画一个压扁的角色轮廓然后叠加混合透明度。优点是性能极低一个 Draw Call 搞定缺点是只能适用于平面地面遇到起伏地形或者曲面台阶时就露馅。如果做的是竞技类小游戏地面是平的角色数量和阴影频率都不高这方案很合适。还有一种更视觉化的方案阴影贴花。做法是先在角色的脚下实时生成一张角色轮廓图然后用 Projector 把它贴到地面上做完形变换让它跟随地面起伏。这种方案在复古 RPG 里很常见它比平面投影阴影多了一点立体感也比实时 Shadow Map 省掉了几百个 Draw Call。它的难点在于角色在走斜坡或上台阶时轮廓贴花要正确跟地面深度对齐不然会出现影子悬空或者插进墙里的诡异效果。5.2 用 ShadowCaster 做双面阴影和镂空阴影默认情况下 Unity 的 ShadowCaster 只绘制正面因为面片的方向决定它是否参与投影。这个规则在某些场景下会造成困扰比如一片草叶、一面栅栏、一段绳索它们都是单面渲染的默认阴影投射出来只有单面的薄片效果。解决思路是给 ShadowCaster Pass 加上 Cull Off让它不管正面反面都往深度图里写Pass { Name ShadowCaster Tags { LightMode ShadowCaster } Cull Off ZWrite On // 顶点位移函数、阴影投射计算 }这样做的好处是阴影更厚实看起来像立体物件坏处是投影区域会增大如果 Shadow Map 分辨率不够边缘锯齿会加重。我自己在做栅栏时用过这个方案配合 tone mapping 之后视觉效果比默认正面投影好很多。镂空阴影是另一个方向。比如一棵树叶子是透明纹理如果你不做特殊处理Unity 会默认把整棵树的完整包围盒投到阴影里结果地面上出现的是一团长方形的阴影而不是树叶的稀疏影子。要解决这个问题需要让 ShadowCaster Pass 也执行 Alpha Test把透贴纹理采样出来当 Alpha 低于阈值时用 clip() 把片元丢弃这样深度图里才会在镂空处留下空洞。float alpha tex2D(_MainTex, uv).a; clip(alpha - _Cutoff);这个技巧对植被、铁丝网、头发等透贴素材非常有效。唯一要注意的是 Alpha Test 的开销在 Shadow Map 渲染阶段同样存在而且 ShadowCaster 阶段的光照计算已经被移除了只保留深度和裁剪逻辑代码上不要误触其他光照函数否则会拉高 Draw Call 数量。5.3 ShadowCaster 写错会有什么典型症状自己写 ShadowCaster 时必须清楚一个隐性要求顶点输出位置必须在光源裁剪空间里。错写成了主摄像机空间会出现一个非常迷惑的效果——模型在地面上看不到影子但当我把摄像机转向光源方向时影子出现在屏幕的正中央。这个问题的排查思路是先确认 LightMode 标签有没有写对再确认顶点变换矩阵是否正确。Unity 的 ShadowCaster Pass 中顶点变换的核心代码是float4 shadowCoord TransformObjectToHClip(v.vertex.xyz);这个函数实际上是先把对象空间顶点转到世界空间再转到灯光投影空间Unity 内置了这个函数的重载会根据 _LightShadowData 自动识别是否启用阴影偏移。另外有个常见问题是你写了 ShadowCaster 但材质上没有勾选 Cast Shadows。Render 组件属性面板里有 Cast Shadows 和 Receive Shadows 两个开关前者控制是否往深度图里写后者控制渲染时是否采样深度图。很多新手把 ShadowCaster 写对了却忘了把场景里物体的 Cast Shadows 打开导致依然没有影子。6. 实战中高频踩坑阴影问题速查与修复实录6.1 阴影闪烁和漏光怎么快速定位阴影闪烁在场景里是最容易出现的通常发生在两种情况下一是平行光角度非常倾斜照射到几乎平行于地面的高模表面二是 Shadow Distance 过大近处阴影分辨率不够。给一个排查思路先把 Shadow Cascade 改成 No Cascades看问题是否消失。如果消失基本就是级联边缘采样导致的问题。然后调 Shadow Depth Bias从 0 开始逐步增加到问题消失为止。如果还不行再调 Normal Bias。很多项目里我发现 Bias 值远没有想象中那么大就够用了在 0.5 到 2.0 之间都能稳定超过 5 就会出现严重漏光。记住 Bias 的单位是相对于 Shadow Map 深度精度的比例不是绝对的米数或厘米数所以不能用长度直觉去判断数值合理性。漏光的典型场景是墙壁根部和地面之间出现一条亮度异常的缝隙。造成原因通常是 Shadow Bias 太大把本应接触到的深度线拉得太远让阴影缩成一个“皮影”此时把 Shadow Depth Bias 调小或清零就能恢复。如果是为了消除噪点才把 Bias 调大那需要换成 Normal Bias 方案不要在 Depth Bias 上猛加。6.2 阴影突然消失的几种情况物体在场景中移动时阴影突然消失最常见的三个原因依次是 Shadow Distance 设置太小、物体超出方向光照射范围、以及材质未开启阴影接收。Shadow Distance 是最容易忽略的。当物体走出阴影距离范围后Unity 完全不渲染它的阴影表现为“影子走一半就没了”。这个值在 Project Settings 里设的是全局默认具体光源上还可以单独覆盖。通常我会在相机控制脚本里做一个动态调整根据当前帧的相机移动速度把 Shadow Distance 在 30 米和 80 米之间平滑切换速度越快拉得越远。材质未开启接收阴影这个更隐蔽。用 URP ShaderGraph 时有个节点叫 Receive Shadows如果你忘了连到光照输出上地面就永远不显示阴影而且渲染时不会报任何警告像是没毛病但其实功能缺失。这种问题看起来很简单实际排查起来特别费时因为你能想到的原因都被排除了最后才发现是材质节点的问题。6.3 阴影效果与项目目标风格冲突时怎么办做写实渲染时阴影越接近真实越好但做风格化渲染阴影的形态可能比真实性更重要。我自己做二次元场景时遇到过一个问题实时阴影的软硬程度和分辨率始终差那么一点意思尤其人物走动时阴影边缘闪烁完全比不上插画那种干净的硬边阴影。这个问题的根治办法是用图像处理的方式处理阴影采样结果拿到 MainLightRealtimeShadow 之后不直接用它做乘法而是做一个阈值映射half shadow MainLightRealtimeShadow(shadowCoord); shadow smoothstep(0.1, 0.35, shadow);这样把中间灰度映射成硬边界看起来就像赛璐璐上色那种两段式阴影。如果还想更精细可以在阴影边缘叠一层噪声扰动做出手绘涂鸦的边缘抖动感这在二次元渲染里是常见的 trick。如果对实时阴影彻底不满意可以用 Baked 光照贴图或者 Light Probe 组做烘焙阴影。烘焙阴影的效果是静态的适合不动的场景。但需要处理动态角色和场景的影子衔接问题否则角色踩进烘焙阴影区域会突然“变亮”或“变淡”。正规做法是静态场景用烘焙阴影动态角色用实时阴影然后通过 Light Probe 或者混合光照模式把两者的强度衔接起来这属于进阶的 Shadowmask 方案。6.4 阴影与原视频、UI 叠加时要注意的层次问题最后提一个容易被忽略点Unity 的阴影是在 3D 渲染阶段完成的它不会自动出现在 UI 层之上。如果你用 Canvas 做 UIUI 图片默认覆盖在摄像机输出之上阴影无论如何不会出现在 UI 元素上除非你特意用相机分层把 UI 和场景合到一起。有两种思路可以解决。第一种是在场景里把 UI 元素设置成 3D 物体放在场景平面上贴上 UI 图片的材质同样走阴影流程这样在空间感上 UI 更像“立体弹窗”。第二种是用后处理做投影阴影伪影比如用深度图做 Sobel 边缘检测把场景中所有物体的轮廓在一个较低的透明度下叠加到 UI 层下方这个方案对性能要求高一些适合全屏 UI 且界面元素较少的界面。我实际做过的项目里需要 UI 有阴影的都是做“牌桌、棋盘”这类场景因为牌面漂在空中的立体感完全靠阴影撑起来。采用第一种思路后牌面阴影的准确性远好于在 UI 上加一个收藏夹式阴影图片而且还能自然响应光源变化。7. 一些实际操作中体会到的细节投影阴影这个东西理论和实操之间隔着一条很宽的河。我刚接触那会儿把 Shadow Distance 调到极远结果画面里远景阴影明显近处却暴露出各种 Bias 引起的闪烁整整调了一个下午才平衡过来。后来总结出一条原则阴影质量优先服务最近的可视距离远景阴影该丢就丢没人会趴在远景地面上去数阴影锯齿。再分享一个调阴影的小技巧先不要开动态光照和漫反射把场景调成 Lambert 纯色然后单独盯着阴影边界看把所有 Bias、分辨率、级联数都调顺了再重新开光照。这样能把阴影问题从光照的干扰中剥离开来定位速度快一倍。工具链方面Unity 自带的 Frame Debugger 是你查看阴影渲染过程的好伙伴它可以一帧一帧地看到 Shadow Map 的生成过程包括每张级联图的绘制内容。如果 Shadow Map 里出现了不该出现的物体或者裁剪范围不对一眼就能看出来比靠猜靠谱得多。最后提醒一句投影阴影没有十全十美的方案实时和性能永远是互相拉扯的。核心思路是想清楚你的画面风格和跑分预算然后在有限资源里把阴影呈现到最顺眼的形态。我在实际项目里一般这样收尾写完 ShadowCaster 或调完 Bias 之后一定会把相机在场景里乱转一遍近景、远景、抬头看天、低头看地确认阴影不会突然抽风再去推进下一个渲染需求。阴影这东西最怕的是一开始看着正常换个角度就露馅所以宁可多花点时间做全角度检查也别上线后被玩家截图打脸。