☰
Unity投影阴影完全指南:Shadow Map原理与ShadowCaster实战
2026/10/1 3:07:05 网站建设 项目流程

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 Shader,Unity 会自动生成 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 节点即可。如果是手写 Shader,URP 下面会看到 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 Cascades,Shadow 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 做 UI,UI 图片默认覆盖在摄像机输出之上,阴影无论如何不会出现在 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 之后,一定会把相机在场景里乱转一遍,近景、远景、抬头看天、低头看地,确认阴影不会突然抽风,再去推进下一个渲染需求。阴影这东西最怕的是一开始看着正常,换个角度就露馅,所以宁可多花点时间做全角度检查,也别上线后被玩家截图打脸。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询