ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity投影阴影原理与自定义Shader接入:从Shadow Mapping到问题排查

2026/10/1 3:48:18 拓冰建站 浏览量
Unity投影阴影原理与自定义Shader接入:从Shadow Mapping到问题排查 第一次正经碰上Unity投影阴影的坑是在一个很普通的小场景里室内一盏平行光模型用的是Standard材质阴影参数也全都打开了结果墙角、椅子边到处是一团团灰蒙蒙的暗斑调到后面阴影干脆整个浮到空中。我那时候对阴影的理解就是灯照不到的地方就是阴影参数挨个拖了一遍纯靠手感碰运气。后来把Light面板、Quality设置、Frame Debugger全过了一遍才算把Unity投影阴影的实现链路彻底想明白。这篇博文就想用从业者之间交流的方式把Unity投影阴影的底层原理、平行光/点光/聚光灯各自的运算差异、自定义Shader接入阴影投射与接收的完整方法以及我实际排查阴影问题时的定位思路整理成一套可以直接参考的笔记。适合刚接触Unity渲染、正在跟阴影较劲的开发者也适合那些想把手写Shader的阴影能力补全的人看完你至少能对阴影为什么是这样的有一个整体判断而不是继续在参数面板里瞎调。1. 投影阴影的底牌一张从光源视角生成的深度图1.1 阴影映射在光源位置放一台只记录深度的相机要搞懂Unity里的投影阴影先别盯着Light组件的阴影选项回到图形学最底层的Shadow Mapping思路。Unity整个实时阴影体系就是建立在这个算法上的只不过它在引擎里帮你把细节全包了。Shadow Mapping的原理其实只有两步。第一步从光源的位置和方向渲染一次场景不输出颜色只输出深度得到一张阴影贴图Shadow Map里面每个纹素记录的是离光源最近的表面距离。第二步从主相机角度正常渲染场景每个像素在光照计算时会重新算出自己到光源的距离然后和阴影贴图里对应的深度做比较如果这个像素比阴影贴图记录的深度更远说明它的正前方还有其他遮挡物它就该处于阴影中如果它本身就在最前面那就是被照亮的区域。生活里可以这么类比光源那边站着一个拿着签到表的守卫签到表上每一格只写了当前最靠近我的那个人是谁。主相机这边的人想确认自己有没有被挡就去对表发现自己不是最靠前的那个那就自觉站进影子里。这套思路放在任何实时引擎里都成立Unity只是把第一遍光源视角渲染封装成了ShadowCaster渲染阶段。这个机制决定了几个重要特性。阴影贴图的分辨率不是无限大的所以阴影边缘天然存在锯齿和采样误差深度值存在浮点精度和量化误差所以必须加Bias偏移来补偿光源的视野范围决定了一张阴影贴图能覆盖多少场景范围太大会导致纹素稀疏阴影又糊又脏。理解了这三点后面遇到的问题基本都有了解释。1.2 深度纹理的分辨率决定了阴影的毛边程度Shadow Map的纹素在场景里的实际尺寸直接决定了阴影边缘的精细度。比如一张1024x1024的阴影贴图覆盖10米宽的场景平均每个纹素大约对应1厘米见方的面积听起来很精细但如果是覆盖100米一个纹素就摊到约10厘米见方阴影边缘自然会出现一格格的大颗粒。Unity的阴影质量设置里Shadow Resolution分为Low512、Medium1024、High2048、Ultra4096很多人习惯直接拉Ultra觉得高分辨率一定好。我实际操作下来的经验是高分辨率只解决了边缘颗粒感但没解决Bias问题反而会因为深度精度更敏感让表面Acne噪点更清晰。所以更合理的做法是先把分辨率定在一个平台能接受的水平再回过头来用Bias把噪点压掉。关于纹素覆盖率有个经验值可以顺手记一下一张1024的阴影贴图覆盖范围最好控制在5到15米之内超过这个范围阴影边缘就会开始露馅。这也是为什么接下来要讲级联阴影——平行光想要大面积可控的阴影质量单靠一张贴图是不够的。1.3 硬阴影、软阴影与PCF开销Unity的Shadow Type下拉框就两个选项Hard Shadows和Soft Shadows但它俩背后是完全不同的计算复杂度。Hard Shadows只做一次深度比较结果是0或1阴影边缘非常锐利锯齿感也很直白。Soft Shadows则是在阴影贴图上做相邻多个纹素的采样通常叫PCFPercentage Closer Filtering取平均值来模拟半影过渡效果自然很多但代价是每个像素要做多次阴影贴图采样Shader里这等于几倍的纹理读取和比较操作GPU开销直线上升。阴影类型实现方式边缘表现性能成本Hard Shadows单次深度采样比较锐利、锯齿感明显低Soft ShadowsPCF 2x2或4x4多次采样柔和半影、过渡自然中到高移动端经常有个尴尬情况开了Soft Shadows帧率掉得肉眼可见。如果项目目标平台是手机我通常建议先上Hard Shadows把阴影距离和分辨率控制好效果已经能接受真要软阴影也只对近距离主角附近的物体开远处的物体走另一个低开销的渲染层级这是URP里做分层的常见思路。2. 光不一样阴影的运算方式也不一样三类光源的差异2.1 平行光为什么必须上级联阴影平行光在Unity里算一个特殊光源它没有位置只有方向整个场景都在它的照射范围内。如果按普通Shadow Mapping的思路只生成一张阴影贴图去覆盖整个视锥体结果必然是近处地面每厘米都铺满高密度纹素远处却稀疏得根本看不出阴影轮廓。Unity解决这个问题的方案是级联阴影Cascaded Shadow Map原理很简单把主相机的视锥体按距离切成几段每一段单独生成一张阴影贴图。近处的段覆盖范围小纹素密集阴影精细远处的段覆盖范围大纹素稀疏但反正远处也不在意细节。Unity的Quality设置里Cascade Count可以设No Cascades、Two Cascades、Four Cascades这个数值不是简单的开/关而是阴影贴图的总预算是固定的级联数越多每个级联分到的分辨率越少但分布更合理。实际项目里我的经验是中小型室内场景用Two Cascades就够了省下来的GPU可以留给模型和特效大世界场景直接用Four Cascades否则远处地面阴影糊成一团和没开阴影差不多。级联阴影还有一个隐形问题就是级联与级联之间的接缝处容易出现一条明显的阴影断层两个方案能缓解一是把级联切分曲线调得偏近一点让高分辨率级联稍微覆盖到中远区域二是让阴影贴图在级联边缘多采样几个纹素做混合但后者需要改管线普通项目不推荐自己动。2.2 点光源六张阴影贴图的显存代价点光源的特点是四面八方全方向发光Shadow Mapping必须覆盖完整的球体方向。Unity的做法不是畸形投影而是生成一个立方体贴图把场景从点光源位置分别往前后左右上下六个方向渲染等于一次要渲染六张阴影贴图。这意味着点光源阴影的开销几乎是一个聚光灯的六倍包括渲染次数、显存占用、深度采样成本。这个代价从数据上看非常明显。一张512x512的阴影贴图如果是聚光灯占用的显存和采样带宽可以忽略换成点光源就是六张512贴图同时常驻而且每一帧若每四个方向都渲染绘制压力是直线上升的。所以大范围、高强度的点光源阴影在移动端通常直接被定性为性能杀手。我在项目里的习惯是点光源的Range尽可能控制在5到10米内阴影贴图分辨率用512就够除非这个点光源是近距离的核心视觉光源。如果一个场景需要多个点光源投射动态阴影就该考虑换一盏平行光或者用烘焙光照分担压力而不是单纯堆分辨率。2.3 聚光灯一张锥形贴图就能解决聚光灯的阴影计算在三种光源里最接近教科书里的Shadow Mapping。它有一个确定的光锥范围从光源位置往锥体方向渲染一张带透视投影的阴影贴图只覆盖几十度角的区域一张512贴图就能提供相当精细的阴影边缘。开销上聚光灯阴影和普通平行光单级联的阴影差不多主要受阴影贴图分辨率和场景中能看到该阴影的物体数量影响。聚光灯阴影在实践中容易踩的坑有两个。一个是Spot Angle和Range必须和阴影贴图投影范围严格对应否则会出现阴影边缘突然截断或整片阴影歪斜的情况。另一个是聚光灯的Cookie纹理遮罩和阴影叠加时如果Cookie分辨率太低阴影边缘会出现雾蒙蒙的色带这往往是烘焙出来的贴图没勾选sRGB选项导致的。2.4 Bias阴影开裂与漂浮的根源Bias是阴影系统里最容易被忽略、也最影响结果的两个参数。Unity的Light组件里提供Bias和Normal Bias它们的字面意思都不难但很多人调了半天还是错。先看Bias深度偏移。阴影贴图的深度是经过量化存储的和主相机渲染时算出的深度之间存在微小误差。当一个表面几乎平行于入射光时表面的不同位置到光源的距离相差极小深度值的误差就可能盖过真实距离差导致表面一部分被判成在阴影里一部分又不在形成密集的亮暗噪点也就是常说的阴影痤疮Acne。加Bias就是在深度比较前把阴影贴图里的深度往后推一点给误差让出空间但推多了阴影就会整个离开物体根部浮到空中这叫Peter Panning。再看Normal Bias。它是在阴影采样时把采样坐标沿顶点法线方向偏移一点专门解决斜面自遮阴影的问题。Normal Bias太大会让紧贴地面的接触阴影消失出现漏光太小则斜面噪点压不住。参数作用过大后果过小后果Bias深度比较偏移阴影悬浮、穿透表面噪点AcneNormal Bias采样坐标沿法线偏移接触阴影丢失、漏光斜面自遮阴影明显我个人的调法优先级是先压Acne噪点再加到刚好没有然后就停手宁可用硬阴影也不要让阴影浮起来。因为噪点还可以用低分辨率阴影贴图模糊掉一部分但Peter Panning一眼就能看出影子没贴地观感伤害更大。3. 自定义Shader要投阴影必须走ShadowCaster Pass这一步3.1 为什么自己写的Shader默认没有阴影很多开发者在用Standard材质时从没想过阴影是哪里来的直到开始写自定义Shader突然发现模型死活不投影子灯光、网格、质量设置全都正常就是没有阴影。原因很直接Unity渲染阴影时只认专门负责光源视角渲染的那个Pass也就是Tag为LightModeShadowCaster的Pass。内置的Standard、Diffuse等Shader都自带这个Pass所以换材质不会丢阴影你自己的Shader只写了Forward光照Pass引擎在阴影渲染阶段扫不到ShadowCaster自然就没有任何东西往阴影贴图里写后面的模型就算开了接收阴影也没阴影可接收。这里还有一个容易被误解的点FallBack关键字的作用。如果你自定义Shader里写了FallBack DiffuseUnity在扫描ShadowCaster Pass时会退而求其次去找FallBack Shader里的ShadowCaster Pass。这就是为什么有些半成品Shader明明自己没写阴影还是能投出影子其实用的是兜底Shader的阴影Pass。这种写法方便但代价是阴影Pass和你的主Shader可能完全不匹配比如本来要做Alpha Test的草叶FallBack的阴影Pass却不做裁切投出来的影子会是一整片实心四边形。3.2 一个精简可靠的ShadowCaster Pass代码下面是内置渲染管线下一个可以运行的ShadowCaster Pass我把关键注释都写在代码里。这个版本的核心思路是顶点阶段通过UnityCG封装的宏完成深度位置计算和Bias偏移片元阶段做alpha裁切让透明镂空材质也能投射出正确的镂空阴影。Shader Custom/DemoShadowCaster { Properties { _MainTex (Texture, 2D) white {} _Cutoff (Alpha Cutoff, Range(0, 1)) 0.5 } SubShader { Tags { RenderTypeOpaque QueueGeometry } // 正常的光照Pass省略只演示阴影相关部分 Pass { Name ShadowCaster Tags { LightModeShadowCaster } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD2; }; sampler2D _MainTex; fixed _Cutoff; v2f vert (appdata v) { v2f o; o.uv v.uv; // 这个宏负责把顶点变换到光源裁剪空间 // 同时应用Unity的阴影Bias偏移 TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 frag (v2f i) : SV_Target { fixed4 tex tex2D(_MainTex, i.uv); clip(tex.a - _Cutoff); // 输出的是深度值不是颜色 SHADOW_CASTER_FRAGMENT(i) } ENDCG } } FallBack Off }这里有个细节值得单独说uv我放在TEXCOORD2而不是TEXCOORD0或1是因为UnityCG的V2F_SHADOW_CASTER宏内部在不同Unity版本里对TEXCOORD0/1的使用并不完全一致为了不让自己的uv和宏内部变量冲突直接往后挪一位最安全。如果你写的Shader不需要alpha裁切完全可以不声明uv结构体里只留V2F_SHADOW_CASTERfrag里直接SHADOW_CASTER_FRAGMENT并返回深度即可性能更好。另外如果你用的是URP或者HDRP这套内置管线的宏只是兼容基础更主流的方式是引用URP的ShadowCasterPass.hlsl或者直接在Shader Graph里连一张MainTexture的Alpha到Surface选项。URP里自定义LitShader做阴影投射的原理一模一样但要额外注意LightMode标签要写成ShadowCasterURP才会把它纳入阴影渲染队列。3.3 接收阴影SHADOW_COORDS / TRANSFER_SHADOW / SHADOW_ATTENUATION能投阴影只是第一步模型还得能接收阴影。接收阴影不是引擎自动完成的需要在光照Pass里手动接入一套阴影采样宏。这套宏的名字在UnityCG里是稳定的三件套SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATION。pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWorld : TEXCOORD1; SHADOW_COORDS(2) }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; o.normalWorld UnityObjectToWorldNormal(v.normal); TRANSFER_SHADOW(o) return o; } fixed4 frag (v2f i) : SV_Target { // shadow接近于1表示在光照中接近0表示在阴影中 fixed shadow SHADOW_ATTENUATION(i); // 后续光照计算里把shadow乘进漫反射项即可 return shadow; } ENDCG }这套宏的机制可以展开说一下。TRANSFER_SHADOW在顶点阶段根据顶点位置和当前渲染的Shadow Map投影矩阵算出一个阴影采样坐标存进v2f里片元阶段SHADOW_ATTENUATION拿着这个坐标去采样阴影贴图再做一次深度比较返回一个0到1之间的阴影衰减值。如果Shader里漏写了#pragma multi_compile_fwdbase阴影相关的关键字不会被编译进去宏可能直接展开为空结果就是所有接收阴影的代码都失效模型永远白亮。这是我在项目里撞过好几次的坑排查优先级很高。4. 阴影问题的三个典型案例从现象到根因的排查链路4.1 表面出现细密噪点阴影痤疮的定位过程现象很典型物体的斜面上出现一层细密的黑点或亮暗相间的马赛克距离稍远看像是蒙了一层灰膜靠近看又像数字噪点。这就是经典的Shadow Acne问题。我的排查链路通常是这么走的先把Light组件的Shadow Type改成Hard Shadows排除Soft Shadows里PCF采样带来的雾气感。打开Frame Debugger确认阴影渲染阶段确实走了并且没有多余的重叠Pass。把Light的Bias从0.05开始逐步加到0.2、0.5观察噪点密度是否下降。如果噪点还有把Quality里的Shadow Resolution降一档用更粗的纹素把微小抖动抹平。如果用的自定义Shader检查ShadowCaster Pass里是否正确地套用了TRANSFER_SHADOW_CASTER_NORMALOFFSET漏了这一行等于完全没有任何Bias噪点必然存在。经验上这个问题90%是Bias过小10%是自定义Shader没做偏移。在URP里还需要再看一下Shadow Settings面板里的Depth Bias和Normal BiasURP把它们从Light组件挪到了Pipe Asset的全局设置里很多人找不到。4.2 阴影整体悬浮或穿透墙壁Bias过大的代价和Acne刚好相反如果阴影不贴在物体根部反而像垫了一层气垫一样浮在空中或者干脆穿到墙的另一面去那基本就是Bias和Normal Bias调得太大。这个现象的视觉破坏力非常高因为它会让所有接触阴影全部失真墙角、桌椅腿、人物脚底全都飘起来。我定位这个问题的标准动作是先把Light组件上Bias和Normal Bias同时降到0看阴影是否回到物体表面。如果确实回到表面但噪点暴增那就是典型的Bias过高盖过了噪点状态。接着从0开始每次只提升一点建议步长0.05每调一次就在物体斜面处看一眼直到噪点刚好消失就停下来。千万不能一上来就加Bias到1.0以上那几乎必然导致悬浮。还有一类悬浮情况跟Bias一点关系没有光源是点光源或聚光灯时Shadow Map的Near Plane太小导致光源周围很近的一段距离内阴影贴图根本没有有效深度信息投影出来的阴影就会被截断或错位。Unity里这类光源没有直接暴露Near Plane参数的情况常见做法是适当缩小光源Range或者把光源移远一些让阴影贴图的有效覆盖区域避开这个问题。4.3 距离一远阴影就消失或级联切换断层第三种高频问题来自阴影距离和级联配置。场景编辑器里看着阴影正常一到实际游戏里走几米远处的阴影说没就没或者在某个特定距离上有一条明显的阴影边界线。第一个关卡是Shadow Distance。Unity默认的阴影距离可能只有几十米。大场景里如果你把Shadow Distance设得很小远处地面自然一片空白。这不是Bug而是引擎为了性能故意限制的因为在阴影距离之外所有灯光都不会参与阴影计算。第二个关卡是级联断层。当级联数从2调到4时如果切分比例设置不合理级联与级联之间的阴影分辨率差异会在远处形成一条肉眼可见的接缝。URP里可以在阴影设置面板调Cascade Splits的分割比例内置管线则可以通过Quality设置和脚本控制。一般把前两级联的覆盖比例压小一点让中远景度数落在低精度级联里视觉上反而更平滑。场景规模建议Shadow Distance建议级联数建议分辨率室内小场景10米内20-30米21024中型室外50米内40-60米42048大世界100米以上80-120米42048-4096这几个数值不是铁律但按这个起点调基本能避开一半的阴影视觉问题。调完记得开Profiler看一眼Shadow Pass的耗时阴影距离和分辨率是线性拉高开销的不是无脑往上堆就好。5. 不用Shadow Map的投影阴影伪阴影、Projector与优化取舍5.1 平面阴影用顶点投影公式手工算阴影在某些场景里Shadow Map其实不是最优解比如高度风格化的手游、地面全是平面的竞技场、或者需要极低Shader开销的休闲游戏。这时候我会直接用手写一个平面阴影Planar Shadow原理是把模型的每个顶点沿光线方向投影到一个平面上渲染成半透明的黑色。投影公式推导起来不复杂。设定平面上一点Q、平面法线N、平行光方向L指向光源方向世界空间下物体有一个顶点V那么从V出发延-L方向的射线会和平面相交于S满足S V - L * t (S - Q) · N 0代入后解得t (V - Q) · N / (L · N)放到Shader里就是float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float distToPlane dot(worldPos - _PlanePos, _PlaneNormal); float t distToPlane / dot(_LightDir, _PlaneNormal); float3 shadowPos worldPos - _LightDir * t; o.pos mul(unity_WorldToClip, float4(shadowPos, 1.0));算出来的shadowPos就是压到地面上的影子顶点。然后让片元输出一个固定的半透明黑色并在frag里根据距离做衰减就行。平面阴影的效果在平地上非常干净利落而且Shader开销几乎可以忽略。限制也很明显只适用于物体投影在单个平面上的场景跨台阶、上坡、绕到凹凸地形里就露馅了。这个方案适合用来做技能指示圈、角色脚下的实时投影或者在低端移动设备上替代实时阴影。5.2 Projector阴影老项目的过渡方案Unity的Projector组件也能做投影阴影做法是在场景里放一个Projector并指定一张阴影纹理它会把纹理沿着投影器锥体投射到所有物体表面。这种做法在十年前的老项目里很常见优点是不依赖Shadow Map对Shader版本和平台兼容性好缺点是阴影只是一个贴花效果不会根据实际遮挡关系变形遇到墙角、柱子会有投影穿帮。现在再用Projector做整体阴影已经不划算但它在某些特效场景还有价值比如人物脚下的光晕投影、临时提示圈、传送门地面特效这些不需要物理正确性的地方Projector比Shadow Map省事得多。5.3 静态场景用烘焙光照与Light Probe还有一个不实时的阴影方案其实最值得认真用静态场景全部烘焙。Lightmap烘焙会把静态物体的阴影直接画进光照贴图里运行时零阴影计算开销质量也高。真正的问题只有一个动态物体在静态场景里没有对应的阴影关系身上不会出现静态物体投下的影子脚下也不会有接触阴影。解决方案是给动态物体放Light Probe组让它们从周围空间采样烘焙好的间接光照至少在明暗方向上有一个近似。如果动态物体本身也需要投射阴影那就把它设成影响光照贴图的动态阴影投射器配合Shadow Map做局部的实时阴影只覆盖玩家附近的一小块区域。这种大部分烘焙局部实时阴影的组合是我在移动端项目里压榨阴影质量的主选方案之一。5.4 性能优化清单先看阴影距离再看分辨率和级联最后把实时阴影的性能优化做一个清单式的总结。优化顺序很重要我建议按下面这个优先级操作因为前面几个变量的性价比是最高的第一优先缩小Shadow Distance。这是最立竿见影的优化项远一点的阴影直接不要。第二优先降低阴影贴图分辨率。移动端从2048降到1024往往看不出太大区别但帧率能回来一截。第三优先减少Cascade Count。大世界场景确实需要4级联但中小项目2级联完全够。第四优先限制投射阴影的光源数量。场景里尽量只保留一盏主光源开启实时阴影。第五优先控制需要投阴影的动态物体数量。远处的、不重要的角色和道具可以把Mesh Renderer的Cast Shadows设为Off或者用LOD让阴影在远距离直接消失。在做完这轮优化后我通常会开一次深度性能测试重点看DrawCall里的ShadowCaster Pass和阴影贴图采样带宽。很多时候真正卡住帧率的不是阴影本身而是阴影贴图覆盖范围内塞进了太多需要渲染的物体导致阴影Pass的顶点处理量爆炸。最后聊聊我踩过几次坑之后的习惯如果现在有人问我调Unity投影阴影第一步应该做什么我会说先开Frame Debugger看阴影渲染阶段到底走了哪个Pass再去看深浅参数。90%的阴影问题在Frame Debugger面前都会现出原形——要么ShadowCaster Pass没被调用要么阴影贴图没进主相机的采样链要么就是Bias数值彻底失衡。另一个小技巧是我一直沿用的在做任何一个项目前先花半小时把灯光、阴影、质量设置、URP管线的Shadow参数全部过一遍把这些数值固化成一个预设。这样后续所有场景都用同一套阴影基准出了问题也容易横向对比。别指望一套参数能通吃所有场景室内、室外、移动端、PC端各存一套这才是避免阴影翻车最实在的办法。