1. 阴影问题源头:Shadow Map是怎么决定你“看没看到影子”的
先讲一个我实际遇到的场景。上周在帮一个Pico4一体机项目做性能与画质调优,项目方连着说了三个问题:地上全是黑点、相机移动时阴影边缘像在跳舞、飞到一定高度后影子全部消失。当时我的第一反应是“这怕不是阴影配置整体拉垮了”。果不其然,把Shadow Distance、Bias、Cascade这些参数从默认值一项项捋下来,三个问题只剩一个半。这篇阴影笔记,就是把那两天踩的坑和后面补课补出来的原理一起记录下来,也方便以后自己回查。
Unity阴影问题看起来简单,实际上牵涉Shadow Map的采样、深度精度、平台渲染差异,甚至跟Shader怎么写都有直接关系。特别是现在很多项目的目标平台从PC转向一体机、WebGL、数字孪生大场景,阴影这块如果不理解底层机制,光靠调Quality面板上的滑条,基本是瞎蒙。
1.1 阴影的本质:从光源视角做一次深度比较
实时阴影的核心机制是Shadow Map(阴影映射)。Unity内置的实时阴影基本都是这个思路:把主光源当作一台摄像机,先从光源方向渲染一张只记录深度信息的图,这张图就是Shadow Map。它记录的是“从光源角度看,场景里最近的表面离光源有多远”。
等到正式渲染物体时,GPU会把当前片元转换到光源空间,比较这个片元的深度值和Shadow Map里对应位置的深度值。如果当前片元离光源更远,说明它被其他物体挡住了,那它就处在阴影里;如果两者很接近,说明它直接被光源照到,不在阴影里。整个过程本质上是“谁更靠近光源”的一次比赛,胜者被照亮,败者进阴影。
这里有个容易混淆的点:方向光(平行光)和点光源/聚光灯生成Shadow Map的方式不一样。平行光没有衰减、方向恒定,所以用正交投影渲染Shadow Map;聚光灯用透视投影;点光需要六张Cube Map才够覆盖周围所有方向。这也是为什么点光源开阴影特别贵,因为它一次要生成六张深度图,内存和填充率都是成倍往上翻的。
1.2 Bias与Shadow Acne:为什么你的地面总是“满脸黑点”
熟悉阴影调参的人一定见过这个现象:地面或者墙面在灯光下出现了密密麻麻的黑点,像长了痱子一样,这就是Shadow Acne,中文社区一般叫“阴影痤疮”或者“阴影自遮挡”。原因是Shadow Map分辨率有限,多个片元可能对应Shadow Map里的同一个texel,深度比较时就会产生误差,于是物体表面出现了“自己遮挡自己”的随机结果。
打个比方,Shadow Map就像一把很粗的尺子,用它量精细的表面距离,量出来的结果天然有误差。误差累积起来,表面就会出现大量细碎的黑点。要解决这个问题,引擎通常会提供一个Bias参数,让阴影采样结果往“更亮”的方向偏移。
Unity里Light组件上有两个关键参数:Bias(深度偏移)和Normal Bias(法线偏移)。Depth Bias是在比较深度时给Shadow Map的深度值加一个常量偏移,让该亮的地方尽量亮回来;Normal Bias则是在渲染Shadow Map时,把接收阴影的表面沿法线方向微微“撑开”,给后续采样留出误差余量。实际操作中这两个参数常常要搭配使用,只调其中一个很难彻底解决问题。
但Bias也不是越大越好。偏移量过大会出现另一个经典问题——Peter Panning,也就是阴影和物体分离,看起来像影子拖在身体后面飘着,非常不自然。所以参数调整是一个“往左压黑点、往右压漏光”的平衡过程。
1.3 软阴影其实是“算出来的边缘过渡”,不是物理软阴影
Unity的软阴影选项用的是PCF(Percentage Closer Filtering,百分比更近过滤)。它做的事情是:采样Shadow Map时,不只看一个点,而是对周围一片区域做多次深度比较,然后把比较结果取平均。这样一来,阴影边缘会出现从暗到亮的过渡带,视觉上就“软”了。
但要注意,这种软阴影只是近似效果,并不是真正的物理软阴影。真实的软阴影来源于面光源面积、物体距离光源的距离、遮挡物距离接收面的距离等因素,需要用到PCSS(Percentage Closer Soft Shadows)或者光线追踪才能模拟得比较真实。Unity内置软阴影只是固定次数的采样平均,效果够用,但别指望它跟影视级效果一样。
PCF的“软”和“贵”是成正比的。采样次数越多,边缘越柔,开销也越大。在移动端和VR/MR设备上,软阴影往往是第一个要降级的选项,因为它的采样开销会直接影响帧率。我一般建议PC端可以用高质量软阴影,移动端用硬阴影或低质量软阴影,不然低端设备很容易在阴影采样上吃满GPU带宽。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 阴影质量面板上的参数:理解了再动手调,别瞎试
2.1 Shadow Distance:阴影只在你身边一定范围内“存在”
Shadow Distance,也就是阴影绘制距离,是判断某个物体要不要接收实时阴影的首要条件。Unity在渲染时会把相机位置作为圆心(实际是视锥相关区域),只对Shadow Distance范围内的物体生成阴影图。超过这个距离的物体,干脆就不进入阴影计算流程。
这个参数对性能的影响非常直接。在一个开放场景里,如果你把Shadow Distance从50米拉到200米,需要传入阴影图的物体数量可能翻好几倍,阴影图填充率也会大幅上涨。PC端还好,移动端和一体机根本扛不住。
我的建议是:游戏类项目PC端50到120米,移动端30到60米,VR/MR设备控制在20到30米;数字孪生或者城市级场景需要覆盖大范围时,不要盲目拉高Shadow Distance,而是配合烘焙阴影或者SSAO做补充,后面第四章会专门讲。
2.2 Shadow Cascades:方向光阴影的分辨率分配策略
Shadow Cascades(级联阴影)是方向光特有的优化手段。它会把Shadow Distance划分成多段,每一段单独生成一张Shadow Map。离相机近的部分用更高分辨率,离得远的部分用较低分辨率。这样做的好处是近处地面阴影细节充足,远处虽然模糊,但人眼在远处也看不清细节,观感损失不大。
Cascade数量通常是1、2、4三档。档位越高,近处阴影越清晰,但Shader里需要额外采样更多阴影图,开销随之增加。PC端4级联是主流配置,移动端建议2级联,性能极限的场景甚至可以只开1级联。在URP里,你还可以通过Shadow Cascades一栏下的Split参数调整各级联的占比,默认的0.1、0.25、0.5一般够用,遇到近景特别吃阴影细节的项目,可以尝试把第一段的占比调低,把更多分辨率让给近处。
2.3 Shadow Resolution:分辨率不是越高画面越好
很多人的第一反应是“阴影不清晰?把阴影图分辨率调高”。这个方向没错,但要注意阴影分辨率翻倍,意味着显存占用和填充开销是平方级增长的。2048×2048的阴影图是四张1024×1024的量,4096再翻四倍。
而且分辨率调到很高之后,如果Bias参数没有跟着调整,反而会让Shadow Acne更加明显,因为采样精度变高了,表面微小起伏的深度误差也被放大。我踩过这个坑:把PC端方向光阴影从2048调到4096,结果地面的黑点比之前更严重,最后发现是Normal Bias没跟着比例调整。
一个比较稳妥的起步配置:PC方向光用2048,移动端用1024;点光源用Cube Map的话,单面分辨率从256或512起步,再根据实际效果往上加。没必要一上来就拉4096,除非你真的在做4K分辨率的写实PC游戏。
3. 实战问题排查链路:闪烁、锯齿、消失、漏光
这一节是整篇笔记里最“实战”的部分。Unity阴影问题热搜词里出现频率最高的就是“阴影闪烁”“阴影锯齿”“阴影消失”这几个表现,我把排查链路的完整思路梳理出来了。
3.1 阴影闪烁(Shadow Acne)的完整定位过程
如果你在项目里看到阴影区域表面有闪烁的黑点,先别急着改参数,按下面步骤走:
第一步,判断是不是Shadow Acne。把相机拉近,看黑点是不是随机分布在受光面上,且随着相机移动黑点位置会有变化。如果是,基本就是深度偏移不够。
第二步,查Bias。内置光源的话,去Light组件上调整Shadow Settings里的Bias和Normal Bias。先把Bias从0.005往上加到0.05左右,看黑点是否减少;不行再微调Normal Bias。要注意,Bias调太大会出现“阴影和物体分离”的漏光,所以每次只改0.005到0.01的粒度,慢慢试。
第三步,如果用了自定义Shader,检查Shader里有没有正确的ShadowCaster Pass。很多第三方模型材质或者自己写的Unlit Shader,如果没有ShadowCaster Pass,在阴影图渲染阶段就会被当作“不存在”,最终表现要么没有投影,要么投影区域出现大量异常黑斑。
第四步,如果问题只在相机远处出现,检查Shadow Distance和Cascade边界。级联和级联的交界处因为深度精度突然变化,经常会出现一圈闪烁的阴影线,这就是“Cascade Border”。解决方式一般是调整级联占比,或者给光影Shader做级联交界处的软过渡处理。
3.2 阴影边缘锯齿和“狗牙”问题
阴影边缘锯齿的根源是Shadow Map分辨率不够,导致边缘轮廓不是平滑直线,而是一格一格的像素台阶。这也是PCF软阴影存在的意义之一——它通过对周围区域采样做平均,让边缘过渡变得自然,锯齿感就减轻了。
如果已经开了软阴影还是锯齿明显,就要考虑两种情况:一是整体阴影图分辨率太低,特别是大场景里,方向光一张2048的图覆盖整个Shadow Distance,远处的物体在阴影图里可能只占几个像素,边缘自然没法看;二是Soft Shadow的质量档位太低,采样次数不足,过渡带只有一两个像素,看起来还是硬的。
这里有个很重要的调优思路:大场景下,优先提高Cascade数量而不是整体分辨率。因为级联会把近处区域单独分配一张高分辨率阴影图,这比让一张大图覆盖整个远近范围要划算得多。
3.3 阴影突然消失:从距离、Shader、相机高度三个方向排查
“物体飞到某个高度或者走到某个位置,影子啪一下没了”是Unity阴影问题里最典型的求助帖内容之一。我的排查顺序是这样的:
先看是不是超出Shadow Distance。把相机拉近物体,如果阴影回来了,那就是距离问题。解决办法要么调大Shadow Distance,要么用分层的阴影策略,比如只对近处重要的物体开实时阴影,远处一律烘焙。
再看Shader。如果一个物体用了没有ShadowCaster Pass的材质,它自己是无法向Shadow Map里写入深度的,所以它不会投影;但更隐蔽的是,如果一个半透明物体写了透明Shader但没写ShadowCaster Pass,它在阴影图里就是“空出来”的,光线会穿透它,结果地面上本该被遮挡的地方出现一片亮光。这种情况需要给透明物体补一个ShadowCaster Pass,或者加一个完全不透明的“投影代理体”。
最后看大场景的相机高度。城市级数字孪生项目里,相机经常从建筑群拉到几百米高空。这时候方向光的Shadow Map是基于相机视锥生成的,高空视角下视锥巨大,近景细节被压缩得几乎没有,阴影可能在某个高度后直接消失。这是引擎对阴影图覆盖范围的正常裁剪,解决办法是把实时阴影限制在一个比较小的半径内,远处的高空视角用烘焙、AO或者其他假阴影来补。
3.4 漏光与“墙角发亮”:阴影和物体分离的经典案例
漏光这个词在不同的Unity阴影问题帖子里的含义不太一样,我这里特指墙角、物体贴地时阴影与物体之间出现一道亮的缝隙。最常见原因就是Normal Bias值过大:物体表面被沿法线方向推得太远,阴影提留在原来的位置,于是两者之间露出了一条“光照带”。
排查时就盯着接触面附近看,把Normal Bias一点一点往下调。调到0.002到0.01之间基本能消除大多数漏光。如果调小之后地面开始出现Shadow Acne,那就配合Depth Bias一起往上加,两手都要动。
还有一种漏光出现在半透明物体上。比如一个玻璃杯,默认透明Shader可能不会生成正确的阴影深度,玻璃在阴影图里等于不存在,它下方的桌面就会“漏光”。我在WebGL项目里遇到过几次,最终都是用不透明代理体(一个简化的圆柱体Mesh,只开启投影)来解决的,简单粗暴但有效。
4. 从PC到Pico4再到WebGL:不同终端的阴影策略
4.1 PC、移动端、WebGL的阴影开销差异
同一个Shadow设置,在不同平台的帧率表现可能天差地别,原因是GPU架构不一样。PC上的独立显卡对Shadow Map的带宽和填充率非常宽容,2048的阴影图加PCF 16次采样基本是毛毛雨;但移动端GPU通常是TBR架构,显存带宽有限,一张过大的阴影图会在渲染时造成显著的带宽压力。
做Unity阴影优化时,我一直遵循一个原则:先确定目标平台,再决定参数下限。PC可以开4级联+2048分辨率+高质量软阴影;移动端开2级联+1024分辨率+硬阴影;WebGL因为是浏览器内运行,纹理上传和内存管理都要克制,通常方向光阴影控制在1024以内,阴影图数量能少就少。
还要补充一点关于WebGL的坑。WebGL项目在浏览器里加载时,Shader编译和纹理上传都是异步进行的,如果网络带宽或者本地的文件系统存储(比如indexedDB这一层)写入失败,会导致部分纹理在运行时缺失。表现出来可能就是某些区域阴影异常或者干脆没有阴影。这种问题跟渲染参数没关系,纯属加载链路问题,排查时要想到这个方向。
4.2 Pico4这类一体机上的VR/MR阴影处理
在一体机上做Unity项目,阴影预算是极度紧张的。Pico4这类设备要同时处理双目渲染(虽然是单Pass Instancing,本质还是两眼的视口渲染),GPU的压力本来就比普通单屏应用大一倍。在这个基础上再开高分辨率实时阴影,很容易直接掉帧。
我给VR/MR项目的阴影配置通常是:主方向光2级联,分辨率1024,阴影距离20到30米,软阴影直接关掉。很多人觉得VR里阴影很关键,其实玩家视线焦点集中在几米内的交互物上,远处阴影根本看不清楚,把经费花在近景接触阴影上更值。
MR场景还有一个特殊性:真实环境亮度通常很高,虚拟物体的阴影看起来会偏淡,甚至像“贴了个灰色水印”。这时候与其提升阴影分辨率,不如把虚拟场景的环境光强度调低,让阴影对比更强,观感反而更自然。这是我在Pico4 MR项目实测下来比较有效的处理方式。
4.3 数字孪生与城市级大场景的阴影规划
用Cesium for Unity做城市孪生项目时,阴影是最容易翻车的模块之一。Cesium的Tileset是动态加载的瓦片网格,无法像普通静态场景那样烘焙光照贴图,所有阴影都得靠实时方案处理。再加上城市场景的光照范围极大,动辄几百米上千米,方向光的Shadow Distance和Cascade很快就会达到极限。
我的做法是分层规划:建筑主体不做实时阴影,用光照贴图烘焙或者用SSAO(屏幕空间环境光遮蔽)模拟建筑底部的接触阴影;动态车辆和人物单独配置小范围内的实时阴影,阴影距离压到50米以内;地面单独做处理,不让建筑实时投影到整片地形上,只保近景小区域的接触感。
这样做的好处非常明显——实时阴影的开销从“整个城市”降到了“玩家周围一小圈”,帧率一下子稳住了。视觉上,除非是航拍俯瞰那种大范围视角,否则玩家站在街道上根本感觉不到远处建筑没有实时影子。
5. 物体级控制与伪阴影:超出参数表的进阶方案
5.1 ShadowCastingMode:让某个物体只投影、不接收,或者反过来
Unity的Renderer组件上有一个ShadowCastingMode枚举,分别对应On(投影且接收)、Off(完全退出阴影系统)、TwoSided(双面投影)、ShadowOnly(只投影不显示自身)。用代码控制起来很方便:
csharp复制MeshRenderer renderer = go.GetComponent<MeshRenderer>();
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.ShadowOnly;
renderer.receiveShadows = false;
ShadowOnly模式在性能优化里非常好用。比如一个角色模型,主模型很复杂,但用来投影的时候你并不需要它真的把模型的所有三角形都写进阴影图,反而可以另挂一个简化的低模作为投影源,主模型不投影。这样角色的实时阴影依旧存在,但阴影图渲染的三角形数量大幅下降。
接收阴影的开关则常用于地面和特效层。地表大面积接收阴影时,阴影图采样开销会被放大,如果场景已经用了烘焙阴影,完全可以关闭动态物体的接收阴影,只保留近距离的接触感。
5.2 自定义Shader的ShadowCaster Pass:控制投影形状
有时候你需要的不是真实模型的精确投影,而是一个便宜且可控的“影子形状”。比如一棵树的优化版本,可以用一个胶囊体替代树冠,在ShadowCaster Pass中把这个胶囊体渲染成阴影图里的树冠形状,省掉真实模型几千个三角形的投影开销。
在Built-in管线里,自定义ShadowCaster Pass的写法大概是这样的:
code复制Shader "Custom/MyShadowCaster"
{
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
Name "ShadowCaster"
Tags { "LightMode"="ShadowCaster" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata v)
{
v2f o;
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
o.pos = UnityClipSpaceShadowCasterPos(worldPos, worldNormal);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
return 0;
}
ENDCG
}
}
}
注意我调用了UnityClipSpaceShadowCasterPos来处理Bias偏移,这是很多人容易漏掉的地方。如果自定义Shader里的ShadowCaster Pass没有正确应用Bias,你的自定义投影会在边缘出现大量Shadow Acne。
URP环境下逻辑类似,但建议直接include URP提供的Shadows.hlsl相关函数,别自己手写一遍,不然又要踩一遍偏移不对的坑。
5.3 伪阴影:性能吃紧时的务实方案
当实时阴影的代价已经高到影响项目上线时,我一般会用伪阴影顶上。伪阴影有几种常见形式。
最基础的是贴地阴影:在角色脚下放一个半透明的黑色圆片,跟随角色移动。圆片保持水平,并且在斜坡上要特别注意贴紧地面,不然会出现“影子飘在空中”的穿帮。这个方案在移动端大量使用,很多老一代手游的角色脚下都是这种影子。
稍微进阶一点的是平面投影阴影(Planar Shadow),做法是把模型的顶点沿着光方向压到一个平面上,形成一个跟随光线方向的形变影子。优点是只要一次简单的矩阵变换就能实现,比Shadow Map省非常多;缺点是只能投影到平面上,遇到台阶、墙壁等复杂遮挡基本没法用。
还有一种我在俯视角RPG里常用的Blob Shadow,用CapsuleCast从光源方向向下发射碰撞检测,检测到地面后就地生成一个暗色贴花。这个方案能适应地形起伏,不像平面投影只能压平面,而且开销很低,适合大量角色同时存在的情况。伪阴影选得好,画面观感完全可以逼近实时阴影,很多玩家根本分不出来。
在我个人实际操作中,伪阴影是优先级很高的备用方案。我会先把它跑通,保证任何机器上都不至于没有影子,等到性能有余量了再逐步替换成实时阴影或者烘焙阴影。这样做的好处是,无论目标设备多拉垮,项目始终有一个“兜底”的阴影方案在,不会出现角色浮空、丢失空间感这种低级穿帮。
