阴影进阶
第 6 章讲了阴影贴图的原理和怎样在自己的 Shader 里接收、投射阴影。本章先把 URP Asset 上每一个阴影参数拆开,用对比图和源码讲清它到底改了什么;再动手做六种项目里常用、但 URP 不直接提供的阴影方案:平面投影阴影、Blob 阴影、自定义阴影颜色、屏幕空间阴影、半透明物体的抖动阴影和接触阴影。
23.1 URP 阴影参数总览
在 URP 里,阴影相关的设置分散在三处:URP Asset 的 和 两栏决定全局行为;每盏 Light 组件上的 一栏决定这盏灯是否投影、用多大强度、是否覆盖全局 Bias;Renderer(Universal Renderer Data)上则可以挂 Screen Space Shadows 这样的 Renderer Feature。下面这张表按 Inspector 的顺序列出 URP 14 里与主光阴影有关的设置,右列给出它在源码里的去向,后面几节逐个配图讲解。
| 位置 | 设置 | 作用(以 URP 14.0.12 源码为准) |
|---|---|---|
| Asset › Lighting | Main Light › Cast Shadows | m_MainLightShadowsSupported,关掉后主光阴影 Pass 不执行,_MAIN_LIGHT_SHADOWS* 关键字都不开 |
| Asset › Lighting | Main Light › Shadow Resolution | m_MainLightShadowmapResolution:256~4096,整张主光阴影图集的边长 |
| Asset › Lighting | Additional Lights › Cast Shadows / Shadow Atlas Resolution / Shadow Resolution Tiers | 附加光阴影图集大小,以及每盏灯可选的 Low/Medium/High 三档分辨率 |
| Asset › Shadows | Max Distance | m_ShadowDistance:离相机多远以内渲染实时阴影 |
| Asset › Shadows | Working Unit | 只影响 Inspector 里级联划分的显示单位(米或百分比),不影响渲染 |
| Asset › Shadows | Cascade Count / Split 1~3 | m_ShadowCascadeCount、m_Cascade2Split/3Split/4Split:级联数与各级占阴影距离的比例 |
| Asset › Shadows | Last Border | m_CascadeBorder:最后一段的淡出比例 |
| Asset › Shadows | Depth Bias / Normal Bias | m_ShadowDepthBias、m_ShadowNormalBias:以“阴影贴图纹素”为单位的偏移量,范围 0~10 |
| Asset › Shadows | Soft Shadows / Quality | m_SoftShadowsSupported、m_SoftShadowQuality:是否允许软阴影及默认 PCF 档位 |
| Light | Shadow Type | No Shadows / Hard Shadows / Soft Shadows |
| Light | Strength | _MainLightShadowParams.x,阴影最终用 LerpWhiteTo(atten, strength) 混合 |
| Light | Bias(Use settings from Render Pipeline Asset / Custom) | UniversalAdditionalLightData.usePipelineSettings 为 false 时用灯自己的 Depth/Normal Bias |
| Light | Soft Shadows Quality | 每盏灯可覆盖 Asset 的软阴影档位(默认 Use settings from Render Pipeline Asset) |
本章的对比图全部在“摄影棚”里用代码切换这些参数后渲染:有公开 setter 的直接改(如 s.Asset.shadowDistance、shadowCascadeCount、shadowDepthBias),没有 setter 的用 SerializedObject 改序列化字段(如 m_MainLightShadowmapResolution、m_SoftShadowQuality)。你在项目里遇到“代码里改不了某个 URP Asset 选项”时,也可以在编辑器工具里用同样的办法。
23.1.1 分辨率:一个纹素有多大
阴影贴图的分辨率本身没有意义,有意义的是“一个阴影纹素(texel)在世界里覆盖多大面积”。方向光的阴影投影是正交的,URP 用一个包住本级级联视锥的球来确定正交投影的范围(这样相机旋转时阴影投影不会抖动尺寸)。设这个球的半径为 r,阴影图一块(tile)的边长为 N 个像素,那么:
这个值也正是 URP 计算 Bias 时用的单位(23.1.4 节)。注意 N 并不总是你在 Asset 上填的分辨率:MainLightShadowCasterPass 会把整张图集切成若干块给各级级联用。源码里 ShadowUtils.GetMaxTileResolutionInAtlas 不断把边长减半,直到放得下所有级联;而 2 级联时图集高度直接减半(mainLightShadowmapHeight >> 1)。结果是:1 级联时 N 等于设置的分辨率,2、3、4 级联时每一块都只有设置值的一半。比如 Asset 填 2048、4 级联,每一级实际只有 1024×1024。

分辨率翻倍,显存和阴影 Pass 的填充量都是 4 倍。URP 的主光阴影图是 16 位深度(k_ShadowmapBufferBits = 16),4096 的图集就要 32 MB 显存,移动端通常用 1024~2048。与其一味加分辨率,不如先缩短阴影距离、调好级联——因为纹素大小正比于 r,把 r 减半和把 N 翻倍效果一样,却不需要多花显存。
23.1.2 Max Distance 与距离淡出
Max Distance 决定阴影覆盖到离相机多远。超出这个距离的物体既不投射也不接收实时阴影。它同时决定了级联球的大小,也就间接决定了纹素尺寸,是阴影质量最重要的一个参数。一个第三人称游戏把阴影距离从 150 米收到 50 米,近处阴影精度会提升 3 倍左右。
为了不让阴影在边界处突然消失,URP 在接收端做淡出。Shadows.hlsl 里的 GetMainLightShadowFade 用的是“到相机距离的平方”:
// 摘自 URP 14 ShaderLibrary/Shadows.hlsl half GetMainLightShadowFade(float3 positionWS) { float3 camToPixel = positionWS - _WorldSpaceCameraPos; float distanceCamToPixel2 = dot(camToPixel, camToPixel); float fade = saturate(distanceCamToPixel2 * float(_MainLightShadowParams.z) + float(_MainLightShadowParams.w)); return half(fade); }
其中的 scale/bias 由 C# 端 ShadowUtils.GetScaleAndBiasForLinearDistanceFade 根据 Max Distance D 和 Last Border b 算出。展开后就是:
也就是从 (1−b)·D 处开始、到 D 处结束的一段过渡(在距离平方上线性)。默认 b = 0.2,阴影在 80% 阴影距离处开始变淡。MainLightShadow() 再用 lerp(realtimeShadow, bakedShadow, fade) 把实时阴影过渡到烘焙阴影(没有烘焙阴影时是 1,即“无阴影”)。如果你的 Shader 只调用了不带位置参数的 GetMainLight(shadowCoord),就拿不到这个淡出,远处会出现一条硬边。本章的 Shader 都用带 positionWS 的重载 GetMainLight(shadowCoord, positionWS, shadowMask)。

23.1.3 级联阴影(CSM)
透视相机近处一个像素覆盖的世界面积小、远处大,而一张正交阴影图在各处的精度是均匀的,这就浪费了远处的纹素、亏待了近处。级联阴影贴图(Cascaded Shadow Maps)把阴影距离沿视线切成 2~4 段,每段单独渲染一块阴影图,近段范围小、精度高,远段范围大、精度低,和透视的需求大致匹配。
接收端选级联的代码非常短,值得一读:
// 摘自 URP 14 ShaderLibrary/Shadows.hlsl half ComputeCascadeIndex(float3 positionWS) { float3 fromCenter0 = positionWS - _CascadeShadowSplitSpheres0.xyz; // …… fromCenter1~3 同理 float4 distances2 = float4(dot(fromCenter0, fromCenter0), dot(fromCenter1, fromCenter1), dot(fromCenter2, fromCenter2), dot(fromCenter3, fromCenter3)); half4 weights = half4(distances2 < _CascadeShadowSplitSphereRadii); weights.yzw = saturate(weights.yzw - weights.xyz); return half(4.0) - dot(weights, half4(4, 3, 2, 1)); }
weights 先标出像素在哪些球内,再用 yzw - xyz 去掉“也在更小的球里”的情况,只保留最先命中的那一级;最后用点积把 one-hot 向量换成索引。所有球都不在时四个权重都是 0,返回 4——这正是“超出阴影距离”的情况。MainLightShadowCasterPass 特意在矩阵数组的末尾多放了一个“空操作”矩阵(注释写明是为了避免越界和分支),所以索引 4 也能安全地取矩阵,再交给距离淡出把阴影过渡掉。整个过程没有分支,GPU 很喜欢这种写法。
案例 23.1级联可视化调试 Shader入门单独打开 ↗
目标:调级联划分时,最直观的办法是直接看到每一级覆盖哪里。写一个调试 Shader,把 ComputeCascadeIndex 的结果涂成颜色,叠在简单光照上。
half4 frag(Varyings i) : SV_Target { float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS); half shadow = MainLightRealtimeShadow(shadowCoord); if (_Mode > 0.5) return half4(shadow.xxx, 1); half3 n = normalize(i.normalWS); Light l = GetMainLight(); half3 shade = (saturate(dot(n, l.direction)) * shadow * 0.8 + 0.25).xxx; #if defined(_MAIN_LIGHT_SHADOWS_CASCADE) half cascade = ComputeCascadeIndex(i.positionWS); // 0..3,超出所有级联球为 4 #else half cascade = 0; #endif static const half3 kColors[5] = { half3(1.0, 0.35, 0.3), half3(0.35, 0.9, 0.4), half3(0.35, 0.55, 1.0), half3(1.0, 0.85, 0.3), half3(0.6, 0.6, 0.6) }; half3 tint = lerp(half3(1, 1, 1), kColors[(int)cascade], _Tint); return half4(shade * tint, 1); }
vec4 frag() { vec4 shadowCoord = TransformWorldToShadowCoord(positionWS); float shadow = MainLightRealtimeShadow(shadowCoord); if (_Mode > 0.5) return vec4(vec3(shadow), 1); vec3 n = normalize(normalWS); Light l = GetMainLight(); vec3 shade = vec3(saturate(dot(n, l.direction)) * shadow * 0.8 + 0.25); // _MAIN_LIGHT_SHADOWS_CASCADE 关键字在网页版是个 uniform(级联数 > 1 时为 1) float cascade = _MainLightShadowsCascade > 0.5 ? ComputeCascadeIndex(positionWS) : 0.0; // 0..3,超出所有级联球为 4 vec3 kColors[5] = vec3[5]( vec3(1.0, 0.35, 0.3), vec3(0.35, 0.9, 0.4), vec3(0.35, 0.55, 1.0), vec3(1.0, 0.85, 0.3), vec3(0.6, 0.6, 0.6) ); vec3 tint = mix(vec3(1, 1, 1), kColors[int(cascade)], _Tint); return vec4(shade * tint, 1); }
有两点要注意。第一,ComputeCascadeIndex 只在 _MAIN_LIGHT_SHADOWS_CASCADE 开启时才有意义:URP 在级联数为 1 时开的是 _MAIN_LIGHT_SHADOWS,此时 _CascadeShadowSplitSpheres* 不会被正确填写,所以要用 #if 包起来。第二,这个 Shader 同时提供了 _Mode = 1 的“只看阴影衰减”模式,23.7 节观察屏幕空间阴影时还会用到。


级联的代价主要在 CPU 和顶点端:每一级都要单独剔除、单独提交一遍投射物。一个在 4 个级联里都可见的物体会被画 4 次。所以移动端常见的配置是 1~2 级联、阴影距离 30~60 米。Split 比例的经验是:让第一级刚好覆盖“玩家最关注的那一圈”(第三人称游戏里大约是角色周围几米到十几米),后面的级联逐级放大。Inspector 里把 Working Unit 切到 Metric,可以直接按米填写。
23.1.4 Depth Bias 与 Normal Bias
阴影映射的本质误差来自“一个纹素只存一个深度,却代表一整块斜面”。接收端把像素深度和纹素深度比较时,同一个表面的不同部分会时而在前、时而在后,形成条纹状的自阴影,这就是阴影痤疮(Shadow Acne)。解决办法是给比较加一个容差,URP 把它放在投射端,在 ShadowCaster 的顶点着色器里移动顶点:
// 摘自 URP 14 ShaderLibrary/Shadows.hlsl float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection) { float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS)); float scale = invNdotL * _ShadowBias.y; // normal bias is negative since we want to apply an inset normal offset positionWS = lightDirection * _ShadowBias.xxx + positionWS; positionWS = normalWS * scale.xxx + positionWS; return positionWS; }
_ShadowBias 由 C# 端 ShadowUtils.GetShadowBias 计算:
方向光的 frustumSize 取 2 / projection.m00,就是 23.1.1 节说的 2r。这说明 Inspector 上的 Bias 不是“米”,而是“纹素”:分辨率降低一半、阴影距离加倍,同样的 Bias 数值在世界里的偏移量会自动变大一倍,所以一般不需要随分辨率反复调。两个 Bias 的几何意义如下图:
你可能会预期把 Depth Bias 和 Normal Bias 都调到 0 就能看到满屏痤疮,实际在 URP 14 里多半看不到。原因在 ShadowUtils.RenderShadowSlice 的第一行:cmd.SetGlobalDepthBias(1.0f, 2.5f)。URP 渲染每一块阴影图时都打开了硬件的常量偏移和斜率偏移(注释里说数值与 HDRP 默认一致),斜面越陡,硬件偏移越大,已经替你挡掉了大部分痤疮。所以 Asset 上的 Bias 更多是在处理硬件偏移管不到的情况:低分辨率、远处级联、开了软阴影之后 PCF 采样到邻近纹素等。


开启软阴影后,GetShadowBias 还会把两个 Bias 再乘以 PCF 核的半径(Medium 为 2.5、High 为 3.5,代码注释说明这是“采样不止一个纹素远”的粗略补偿)。所以从硬阴影切到软阴影,你会看到影子稍微“瘦”一点、离接触点稍远一点,这是预期行为。另外点光源的 Normal Bias 在 URP 里被固定为 0,与内置管线的约定相同。
调 Bias 的顺序建议:先用默认的 1/1;出现痤疮先加 Depth Bias,每次 0.5;物体脚下影子脱开就回退 Depth Bias、改加一点 Normal Bias;细物体影子断开就降低 Normal Bias。某盏灯需要特殊处理时,在 Light 组件上把 Bias 切到 Custom,不要为了一盏灯改全局。
23.1.5 软阴影与质量档位
硬阴影对阴影图只采样一次(硬件比较采样器自带 2×2 双线性比较,边缘有一个纹素宽的过渡)。软阴影则在周围多采样几次再加权平均,这就是 PCF(Percentage Closer Filtering)。URP 14 提供三档:
| 档位 | 静态分支关键字 | 采样方式 | 比较采样次数 |
|---|---|---|---|
| Low | _SHADOWS_SOFT_LOW | 4 个固定偏移点取平均 | 4 |
| Medium | _SHADOWS_SOFT_MEDIUM | 5×5 帐篷滤波(Tent),SampleShadow_ComputeSamples_Tent_5x5 | 9 |
| High | _SHADOWS_SOFT_HIGH | 7×7 帐篷滤波,SampleShadow_ComputeSamples_Tent_7x7 | 16 |
帐篷滤波利用了硬件比较采样器自带的双线性:每次比较采样其实已经覆盖 2×2 个纹素,再配合精心计算的采样位置和权重,9 次就能得到 5×5 帐篷核的结果。关键字方面,URP 14 的 Lit.shader 声明的是 multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH。至于 URP 实际打开哪一个,要看 ShadowUtils.SetSoftShadowQualityShaderKeywords:在绝大多数平台上 SupportsPerLightSoftShadowQuality() 返回 true,URP 只打开 _SHADOWS_SOFT,在 SampleShadowmapFiltered 里按每盏灯的档位(_MainLightShadowParams.y)做动态分支,所以每盏灯可以有不同档位;只有移动 VR 平台(源码注释提到 Quest 2 与 HoloLens)出于性能考虑改用 _SHADOWS_SOFT_LOW/MEDIUM/HIGH 静态分支,按 Asset 的档位统一处理。自己写的 Shader 要把这五个都声明上,否则软阴影会退化成硬阴影。

注意 URP 的软阴影是固定宽度的滤波,半影宽度不随遮挡物与接收面的距离变化,物理上并不正确(真实阴影离接触点越远越虚)。它的价值是“抗锯齿”。如果需要随距离变化的软阴影,要用 PCSS 一类的方法,URP 14 没有内置。移动端建议用 Low 或 Medium,并把它和分辨率一起权衡:1024 + Medium 往往比 2048 + 硬阴影观感更好、也更省。
23.2 平面投影阴影
在很多移动端游戏(尤其是俯视角、地面平坦的 MOBA、棋牌、跑酷)里,角色阴影并不来自阴影贴图,而是把角色网格“拍扁”到地面上画一遍。这叫平面投影阴影(Planar Shadow)。它的优点是:没有阴影 Pass,没有阴影贴图显存,精度等于角色网格本身的精度,永远锐利;缺点是只能投到一个平面上,不能投到其他物体上,也不能自阴影。
矩阵推导
设地面是平面 n·p + d = 0,主光方向 L 是指向光源的单位向量(URP 里方向光的 _MainLightPosition.xyz 正是它)。从顶点 P 沿 −L 方向发射射线 P − tL,与平面相交时:
写成齐次坐标下的矩阵(乘以 n·L 消掉分母,交给透视除法处理):
这就是经典的“平面投影矩阵”M = (n·L)I − L nᵀ(在 4×4 齐次形式下)。可以在 C# 里每帧算好传给 Shader,用 mul(M, positionWS) 一步得到投影点。最常见的情况是水平地面 y = h,即 n = (0,1,0)、d = −h,公式退化成:
只需要一次除法和一次乘加,比乘 4×4 矩阵更省,本案例就用这个形式。
案例 23.2平面投影阴影 + 模板防重叠进阶单独打开 ↗
目标:给角色 Shader 增加一个 Pass,把网格投影到 _PlaneHeight 高度的地面上,画成半透明阴影色;用模板测试保证同一像素只被阴影覆盖一次。
// 案例 23.3:平面投影阴影(Planar Shadow)。 // 第一个 Pass 正常着色;第二个 Pass(SRPDefaultUnlit)把同一个网格沿主光方向“拍扁”到地面上, // 输出半透明的阴影色,并用模板测试保证同一个像素只被阴影覆盖一次,避免重叠部分变黑。 Shader "Cookbook/Ch23/PlanarShadow" { Properties { [MainColor] _BaseColor ("Base Color", Color) = (0.9, 0.6, 0.3, 1) _ShadowColor ("Shadow Color", Color) = (0.05, 0.06, 0.1, 0.65) _PlaneHeight ("Plane Height (世界空间 y)", Float) = 0 _ShadowFalloff ("Height Falloff", Range(0, 2)) = 0.35 [Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp ("Stencil Comp", Float) = 6 // 6 = NotEqual } SubShader { // 排在普通不透明物体之后,保证地面已经画好,阴影才能混合到地面颜色上 Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry+10" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; half4 _ShadowColor; float _PlaneHeight; float _ShadowFalloff; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; half3 normalWS : TEXCOORD0; }; Varyings vert(Attributes v) { Varyings o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); o.normalWS = TransformObjectToWorldNormal(v.normalOS); return o; } half4 frag(Varyings i) : SV_Target { half3 n = normalize(i.normalWS); Light l = GetMainLight(); half3 c = _BaseColor.rgb * (l.color * saturate(dot(n, l.direction)) + SampleSH(n)); return half4(c, 1); } ENDHLSL } Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; half fade : TEXCOORD0; }; Varyings vert(Attributes v) { Varyings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L float3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; float3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting o.positionCS = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 o.fade = saturate(1.0 - height * _ShadowFalloff); return o; } half4 frag(Varyings i) : SV_Target { return half4(_ShadowColor.rgb, _ShadowColor.a * i.fade); } ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex Ch23DepthOnlyVert #pragma fragment Ch23NullFrag #include "Assets/Cookbook/Ch23/Ch23Common.hlsl" ENDHLSL } } }
// 案例 23.3:平面投影阴影(Planar Shadow)(GLSL 版)。 // 第一个 Pass 正常着色;第二个 Pass(SRPDefaultUnlit)把同一个网格沿主光方向“拍扁”到地面上, // 输出半透明的阴影色,并用模板测试保证同一个像素只被阴影覆盖一次,避免重叠部分变黑。 Shader "Cookbook/Ch23/PlanarShadow" { SubShader { // 排在普通不透明物体之后,保证地面已经画好,阴影才能混合到地面颜色上 Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry+10" } GLSLINCLUDE uniform vec4 _BaseColor; uniform vec4 _ShadowColor; uniform float _PlaneHeight; uniform float _ShadowFalloff; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } GLSLPROGRAM VARYING vec3 normalWS; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); normalWS = TransformObjectToWorldNormal(normalOS); } #endif #ifdef FRAGMENT void main() { vec3 n = normalize(normalWS); Light l = GetMainLight(); vec3 c = _BaseColor.rgb * (l.color * saturate(dot(n, l.direction)) + SampleSH(n)); fragColor = vec4(c, 1); } #endif ENDGLSL } Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back GLSLPROGRAM VARYING float fade; #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L vec3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; vec3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting gl_Position = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 fade = saturate(1.0 - height * _ShadowFalloff); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(_ShadowColor.rgb, _ShadowColor.a * fade); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #define CH23_DEPTH_PASS #include "Ch23/Ch23Common.glsl" ENDGLSL } } }
为什么用 SRPDefaultUnlit。URP 的不透明绘制会同时收集 SRPDefaultUnlit、UniversalForward、UniversalForwardOnly 三种 LightMode 的 Pass。一个 Shader 里同时有 UniversalForward 和 SRPDefaultUnlit 两个 Pass,物体就会被画两遍——这是 URP 里实现“多 Pass 效果”(描边、平面阴影)最简单的方式,代价是这两个 Pass 不能被 SRP Batcher 合并成一次绘制。如果项目里平面阴影物体很多,也可以把阴影 Pass 单独做成一个 LightMode(如 "PlanarShadow"),再用 RenderObjects Renderer Feature 在不透明物体之后统一绘制,第 25 章会讲 RenderObjects 的用法。
Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; half fade : TEXCOORD0; }; Varyings vert(Attributes v) { Varyings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L float3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; float3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting o.positionCS = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 o.fade = saturate(1.0 - height * _ShadowFalloff); return o; } half4 frag(Varyings i) : SV_Target { return half4(_ShadowColor.rgb, _ShadowColor.a * i.fade); } ENDHLSL }
Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back GLSLPROGRAM VARYING float fade; #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L vec3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; vec3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting gl_Position = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 fade = saturate(1.0 - height * _ShadowFalloff); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(_ShadowColor.rgb, _ShadowColor.a * fade); } #endif ENDGLSL }
为什么要模板测试。阴影是半透明混合,同一个像素被两层三角形覆盖就会变成 1−(1−a)² 的浓度。角色的身体和手臂、两个角色之间,投影都会重叠。模板设置 Ref 1 / Comp NotEqual / Pass Replace 的含义是:模板缓冲里这个像素还不是 1 才允许画,画完立刻写成 1,于是后来的阴影三角形全部被拒绝。所有角色共用 Ref 1,角色之间的重叠也一并消除。
为什么 Queue 是 Geometry+10。阴影 Pass 不写深度、要和地面颜色混合,所以必须在地面画完之后再画。不透明队列内部是按距离从前往后排序的,地面这种大物体的排序位置不可预测,干脆把角色材质的队列推后一点,保证地面先画。
高度淡出。fade = saturate(1 − height × _ShadowFalloff) 让离地越高的部位投下的影子越淡,模拟真实阴影随距离变虚。片元透明度与顶点高度相关,同一块阴影内部会有渐变。


常见坑:一是光线接近水平时 L.y 趋于 0,投影被拉到无穷远,代码里用 max(L.y, 1e-3) 防止除零,项目里最好再限制最大投影长度。二是投影出的三角形落在地面高度,必须轻微抬高(这里是 2 毫米)或用 Offset 避免 Z-Fighting。三是不平整的地面(坡道、台阶)不适用,可以只在角色脚下的局部地面取一个平均法线和高度,用通用的 n·p + d = 0 版本矩阵。四是 URP 的 Deferred 路径会使用模板缓冲的高位,用平面阴影时注意不要和它的模板位冲突(可以设置 ReadMask/WriteMask 只用低位)。
23.3 Blob 阴影
比平面阴影更便宜的是 Blob 阴影:在物体脚下放一张圆形的“影子贴片”,完全不关心物体的形状。它几乎没有任何开销——一个四边形、一次很简单的片元计算——却能解决“物体飘在空中,不知道离地多高”这个最影响空间感的问题。大量小兵、掉落物、远景角色、低端机降级方案都常用它。
案例 23.3跟随物体的 Blob 阴影入门单独打开 ↗
目标:一个 Shader 画出中间实、边缘软的圆斑;一个 C# 组件每帧把它贴到物体正下方的地面上,并根据离地高度放大、变淡。
half4 frag(Varyings i) : SV_Target { // d:0 在圆心,1 在 Quad 内切圆边缘 half d = length(i.uv - 0.5) * 2.0; // 1 - smoothstep 做出中心实、边缘软的衰减;Softness 越大,实心区越小 half mask = 1.0 - smoothstep(1.0 - _Softness, 1.0, d); mask *= mask; // 平方让过渡更接近真实半影 half3 c = lerp(half3(1, 1, 1), _ShadowColor.rgb, mask * _Strength); return half4(c, 1); }
vec4 frag() { // d:0 在圆心,1 在 Quad 内切圆边缘 float d = length(uv - 0.5) * 2.0; // 1 - smoothstep 做出中心实、边缘软的衰减;Softness 越大,实心区越小 float mask = 1.0 - smoothstep(1.0 - _Softness, 1.0, d); mask *= mask; // 平方让过渡更接近真实半影 vec3 c = mix(vec3(1, 1, 1), _ShadowColor.rgb, mask * _Strength); return vec4(c, 1); }
混合模式用的是 Blend DstColor Zero,即“结果 = 地面颜色 × 片元颜色”。片元颜色在圆外是 1(不改变地面),在圆心是阴影色。相乘比 Alpha 混合更符合阴影的物理意义:阴影是“少了光”,地面原本亮的地方变暗多,原本暗的地方变暗少,不会像 Alpha 混合那样把所有颜色往同一个灰色拉。队列放在 Transparent-100,保证在不透明物体(地面)之后、普通透明物体之前绘制。
public void Apply() { if (target == null) return; if (m_Renderer == null) m_Renderer = GetComponent<Renderer>(); Vector3 origin = target.position; Vector3 groundPos = new Vector3(origin.x, fallbackGroundY, origin.z); Vector3 groundNormal = Vector3.up; if (Physics.Raycast(origin, Vector3.down, out var hit, maxHeight * 4f, groundMask, QueryTriggerInteraction.Ignore)) { groundPos = hit.point; groundNormal = hit.normal; } float height = Mathf.Max(0f, origin.y - groundPos.y); float t = Mathf.Clamp01(height / maxHeight); // Quad 默认朝 -Z,旋转到“正面朝上”并贴合地面法线 transform.SetPositionAndRotation(groundPos + groundNormal * 0.005f, Quaternion.FromToRotation(Vector3.back, groundNormal)); transform.localScale = Vector3.one * baseSize * (1f + growth * t); // 用 MaterialPropertyBlock 改每个阴影自己的浓度,不必为每个单位复制材质。 // 注意:带 MPB 的 Renderer 不再走 SRP Batcher;单位很多时改用 GPU Instancing 或顶点色。 m_Block ??= new MaterialPropertyBlock(); m_Renderer.GetPropertyBlock(m_Block); m_Block.SetFloat(s_Strength, strength * (1f - t)); m_Renderer.SetPropertyBlock(m_Block); }
// 案例 23.4:让一个 Blob 阴影 Quad 跟随目标物体(网页版,对应 Ch23/BlobShadow.cs)。 // 每帧从目标向下“找地面”,把 Quad 贴到地面上、对齐地面法线, // 并根据离地高度放大、变淡(物体跳得越高,影子越大越虚)。 // 网页里没有物理系统,射线检测简化为:地面就是 y = fallbackGroundY 的水平面(书里的场景正是如此)。 export default class BlobShadow { constructor(engine, json, obj, scene) { this.engine = engine; this.obj = obj; this.scene = scene; const d = json.data || {}; this.d = { baseSize: d.baseSize ?? 1, growth: d.growth ?? 0.8, maxHeight: d.maxHeight ?? 3, strength: d.strength ?? 0.8, fallbackGroundY: d.fallbackGroundY ?? 0, }; // 导出数据里引用的 Transform 以 {component: 物体序号} 表示 const t = d.target && d.target.component; this.target = t !== undefined && t !== null ? scene.objects[t] : null; } update() { this.apply(); } apply() { const target = this.target, d = this.d; if (!target) return; const m = target.matrix; const origin = [m[12], m[13], m[14]]; const groundPos = [origin[0], d.fallbackGroundY, origin[2]]; const height = Math.max(0, origin[1] - groundPos[1]); const t = Math.min(1, Math.max(0, height / d.maxHeight)); // Quad 默认朝 -Z,旋转到“正面朝上”(绕 X 轴 +90°,与 Quaternion.FromToRotation(back, up) 相同) const s = d.baseSize * (1 + d.growth * t); const p = [groundPos[0], groundPos[1] + 0.005, groundPos[2]]; this.scene.setMatrix(this.obj, new Float32Array([s, 0, 0, 0, 0, 0, s, 0, 0, -s, 0, 0, p[0], p[1], p[2], 1])); if (this.obj.renderer) { const c = this.obj.renderer.bounds[0]; c[0] = p[0]; c[1] = p[1]; c[2] = p[2]; } // C# 用 MaterialPropertyBlock 改每个阴影自己的浓度;网页版对应“逐物体 uniform” this.obj.uniforms = this.obj.uniforms || {}; this.obj.uniforms._Strength = d.strength * (1 - t); } }
组件用一次向下的射线找地面,命中则贴合地面法线,打不到碰撞体时退回到一个固定高度。浓度用 MaterialPropertyBlock 按实例设置,避免为每个单位复制材质。但要清楚它的代价:带 MaterialPropertyBlock 的 Renderer 不再走 SRP Batcher。单位数量上百时,更好的做法是开启 GPU Instancing(Shader 里已经声明了 multi_compile_instancing,再把 _Strength 放进 UNITY_INSTANCING_BUFFER),或者干脆用一个粒子系统、一个合并的动态网格一次画完所有 Blob。

23.4 自定义阴影颜色
URP Lit 的阴影是“少了直接光”,阴影区只剩环境光,颜色由环境光(天空球谐)决定。写实项目里这正是想要的;但风格化项目常常希望阴影是指定的颜色:冷蓝色的影子让画面更通透,紫色的影子带出插画感。直接把阴影区乘一个颜色并不好看,因为阴影区原本的亮度来自环境光,乘色只会让它更脏。更好的思路是:直接光不再是 lightColor × N·L × shadow,而是在“阴影色”和“灯光色”之间插值。
案例 23.4可着色的阴影入门单独打开 ↗
half4 frag(Varyings input) : SV_Target { half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; half3 normalWS = normalize(input.normalWS); // 逐像素求阴影坐标:同时兼容单级联 / 多级联 / 屏幕空间阴影三种关键字 float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS); // 带 positionWS 的重载会顺带处理“阴影距离淡出”(GetMainLightShadowFade) Light mainLight = GetMainLight(shadowCoord, input.positionWS, half4(1, 1, 1, 1)); half NdotL = saturate(dot(normalWS, mainLight.direction)); half shadow = mainLight.shadowAttenuation; // lit:0 = 完全不受光,1 = 完全受光 // _TintTerminator=1 时背光面(NdotL 小)也算“阴影”,整体风格更统一; // _TintTerminator=0 时只给投射阴影上色,明暗交界线保持 Lambert 的自然过渡 half lit = _TintTerminator > 0.5 ? NdotL * shadow : shadow; half3 shadowTint = lerp(half3(0, 0, 0), _ShadowColor.rgb, _ShadowColor.a); half3 direct = lerp(shadowTint, mainLight.color, lit); if (_TintTerminator < 0.5) direct *= NdotL; // 只染投射阴影时仍需要 N·L half3 ambient = SampleSH(normalWS) * _AmbientScale; half3 color = albedo * (direct + ambient); color = MixFog(color, input.fogFactor); return half4(color, 1); }
vec4 frag() { vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb; vec3 n = normalize(normalWS); // 逐像素求阴影坐标:同时兼容单级联 / 多级联 / 屏幕空间阴影 vec4 shadowCoord = TransformWorldToShadowCoord(positionWS); // 带 positionWS 的重载会顺带处理“阴影距离淡出”(GetMainLightShadowFade) Light mainLight = GetMainLight(shadowCoord, positionWS, vec4(1, 1, 1, 1)); float NdotL = saturate(dot(n, mainLight.direction)); float shadow = mainLight.shadowAttenuation; // lit:0 = 完全不受光,1 = 完全受光 // _TintTerminator=1 时背光面(NdotL 小)也算“阴影”,整体风格更统一; // _TintTerminator=0 时只给投射阴影上色,明暗交界线保持 Lambert 的自然过渡 float lit = _TintTerminator > 0.5 ? NdotL * shadow : shadow; vec3 shadowTint = mix(vec3(0, 0, 0), _ShadowColor.rgb, _ShadowColor.a); vec3 direct = mix(shadowTint, mainLight.color, lit); if (_TintTerminator < 0.5) direct *= NdotL; // 只染投射阴影时仍需要 N·L vec3 ambient = SampleSH(n) * _AmbientScale; vec3 color = albedo * (direct + ambient); color = MixFog(color, fogFactor); return vec4(color, 1); }
插值参数 lit 有两种取法。_TintTerminator = 1 时,lit = N·L × shadow:背光面和投射阴影统一用阴影色,适合卡通和插画风格,画面里所有“暗部”色相一致。_TintTerminator = 0 时只用 shadow 插值,再整体乘 N·L:明暗交界线保持 Lambert 的自然过渡,只有投射阴影区域换成阴影色,适合在写实画面里做轻微的色彩倾向。_ShadowColor.a 控制阴影色的强度,a = 0 时退化成普通的 Lambert。
阴影坐标这里在片元阶段用 TransformWorldToShadowCoord(positionWS) 计算,而不是在顶点阶段算好插值。原因是:多级联时相邻顶点可能落在不同级联,插值后的坐标会错;屏幕空间阴影模式下这个函数返回的是屏幕坐标,也是逐像素算才正确。URP Lit 在 _MAIN_LIGHT_SHADOWS(单级联)时会在顶点阶段算(宏 REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR),省一点片元开销,自己写时逐像素算最省心。

这个思路也可以反过来用在“阴影里的物体颜色”上:在 direct 里根据阴影值切换两套色板(受光色、阴影色),就是很多卡通渲染的“双色”做法,与第 14 章的 Ramp 思路相通。
23.5 屏幕空间阴影
URP 14 自带一个名为 Screen Space Shadows 的 Renderer Feature(Runtime/RendererFeatures/ScreenSpaceShadows.cs)。在 Renderer 的 Inspector 里点 即可启用。它的流程是:
- 在深度预渲染之后(Forward 路径是
AfterRenderingPrePasses + 1,Deferred 路径是AfterRenderingGbuffer),用一个全屏 Pass 读取_CameraDepthTexture,对每个像素重建世界坐标,计算主光阴影(包括级联选择和软阴影滤波),结果写入一张 R8 的全屏纹理_ScreenSpaceShadowmapTexture。 - 把关键字从
_MAIN_LIGHT_SHADOWS / _MAIN_LIGHT_SHADOWS_CASCADE切换成_MAIN_LIGHT_SHADOWS_SCREEN。 - 不透明物体着色时,
MainLightRealtimeShadow不再采样阴影图,而是用屏幕 UV 读这张纹理,一次点采样。 - 在透明物体之前(
BeforeRenderingTransparents),另一个 Pass 把关键字切回普通的阴影模式,因为透明物体不在深度图里,不能用屏幕空间结果。
全屏 Pass 的 Shader 几乎就是几行:
// 摘自 URP 14 Shaders/Utils/ScreenSpaceShadows.shader float3 wpos = ComputeWorldSpacePosition(input.texcoord.xy, deviceDepth, unity_MatrixInvVP); float4 coords = TransformWorldToShadowCoord(wpos); half realtimeShadow = MainLightRealtimeShadow(coords); return realtimeShadow;
它的收益在于把“级联选择 + PCF 多次采样”从每个不透明片元挪到了每个屏幕像素各做一次。场景里 Overdraw 高(同一像素被多个不透明物体反复着色)、软阴影档位又高的时候,这能省下可观的开销;反过来,它需要深度预渲染(多画一遍深度)和一张全屏纹理,在 Overdraw 低、带宽紧张的移动端反而可能更慢。是否值得要在目标设备上测。
案例 23.5让自定义 Shader 支持屏幕空间阴影入门单独打开 ↗
Lit 已经支持这个模式。自己写的 Shader 要支持,只需两点:一是在 multi_compile 里加上 _MAIN_LIGHT_SHADOWS_SCREEN;二是阴影坐标用 TransformWorldToShadowCoord 或 GetShadowCoord(vertexInput) 计算——它们内部已经处理了屏幕空间分支(返回屏幕坐标而不是阴影图坐标)。本章所有 Shader 都满足这两点。最常见的错误是只声明了 _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE:开启屏幕空间阴影后 URP 关掉了这两个关键字、打开了 _MAIN_LIGHT_SHADOWS_SCREEN,你的 Shader 找不到对应变体,只能用“无阴影”的那个变体,表现就是“加了 Screen Space Shadows 之后我的物体没影子了”。

_ScreenSpaceShadowmapTexture 的内容。球体背光面的黑色是它自己挡住自己的结果,最终着色时会被 N·L 乘掉23.6 半透明物体的阴影
阴影贴图只存一个深度,表达的是“这里有没有被挡住”,表达不了“被挡住了一半”。URP Lit 的处理很简单:透明材质照样有 ShadowCaster Pass,投射的是完全不透明的阴影(开了 Alpha Clipping 时按阈值裁剪)。于是一块淡淡的玻璃会在地上投下一块漆黑的影子。
抖动阴影(Dithered Shadow)用空间换透明度:在 ShadowCaster 里,按透明度 alpha 有规律地丢弃一部分像素。alpha = 0.3 就只留约 30% 的阴影像素。这些像素在阴影图上是一片规则的网点,接收端的软阴影 PCF 本来就会对周围多个纹素求平均,网点被平均成大约 30% 浓度的阴影。
clip;PCF 把规则的网点平均成灰度案例 23.6半透明物体的抖动阴影进阶单独打开 ↗
// 4×4 Bayer 矩阵:相邻阈值尽量分散,丢弃的像素在空间上均匀 static const half kBayer4x4[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 }; half4 frag(Varyings i) : SV_Target { half alpha = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).a * _BaseColor.a * _ShadowAlphaScale; if (_DitherShadow > 0.5) { // 在 ShadowCaster 里,SV_POSITION.xy 就是阴影贴图上的像素坐标 uint2 p = uint2(i.positionCS.xy) & 3; half threshold = (kBayer4x4[p.y * 4 + p.x] + 0.5) / 16.0; clip(alpha - threshold); } return 0; }
// 4×4 Bayer 矩阵:相邻阈值尽量分散,丢弃的像素在空间上均匀 const float kBayer4x4[16] = float[16]( 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0 ); void main() { float alpha = texture(_BaseMap, uv).a * _BaseColor.a * _ShadowAlphaScale; if (_DitherShadow > 0.5) { // 在 ShadowCaster 里,gl_FragCoord.xy 就是阴影贴图上的像素坐标 uvec2 p = uvec2(gl_FragCoord.xy) & 3u; float threshold = (kBayer4x4[p.y * 4u + p.x] + 0.5) / 16.0; if (alpha - threshold < 0.0) discard; } fragColor = vec4(0.0); }
ShadowCaster 片元着色器里的 SV_POSITION.xy 是阴影贴图上的像素坐标,直接对 4 取模就能得到 Bayer 矩阵的位置。Bayer 矩阵的特点是相邻阈值在空间上尽量分散,所以任意 alpha 下保留的像素都比较均匀,平均后不会出现明显的斑块。透明度同时乘上了贴图的 alpha 通道,带透明纹理的物体(比如彩色玻璃、树叶的半透明边缘)也能投出对应的花纹。

这个方法依赖软阴影:硬阴影下网点会直接显现成规则的花纹。阴影分辨率较低、网点大于屏幕像素时也会看到纹理感,此时可以把 Bayer 换成蓝噪声贴图,或者在接收端用 High 档 PCF。另一个要注意的是,透明物体自己的 Forward Pass 一般不接收自身的抖动阴影(会看到自己身上的网点),本例的透明 Pass 干脆不接收阴影。
23.7 接触阴影
阴影贴图的精度受限于纹素大小。即使 4096 分辨率,在 50 米阴影距离下一个纹素也有一两厘米;再加上 Bias 的偏移,小石子、角色脚底、物体与地面的接触缝这些尺度在几厘米的细节,往往得不到阴影,画面就显得“飘”。接触阴影(Contact Shadows)是一种屏幕空间的补救:从每个像素出发,沿着朝向光源的方向在屏幕上步进一小段距离,如果途中有深度缓冲里更靠前的表面挡住了光线,就认为这里有阴影。它只负责“近距离”这一小段,远处的大阴影仍交给阴影贴图。
案例 23.7用 Renderer Feature 实现接触阴影高阶单独打开 ↗
目标:仿照 URP 自带的 Screen Space Shadows 的结构,写一个 Renderer Feature,在深度预渲染之后生成一张全屏的接触阴影纹理 _ContactShadowTexture;接收物体的 Shader 用屏幕 UV 读它,和阴影贴图的结果取最小值。Renderer Feature 的完整生命周期在第 25 章详细讲,这里关注阴影本身。
class ContactShadowPass : ScriptableRenderPass { static readonly int s_Params = Shader.PropertyToID("_ContactParams"); static readonly int s_LightDir = Shader.PropertyToID("_ContactLightDir"); readonly ProfilingSampler m_Sampler = new ProfilingSampler("ContactShadows"); RTHandle m_Target; Material m_Material; Settings m_Settings; Vector3 m_LightDir; public void Setup(Settings s, Material material, Vector3 lightDir) { m_Settings = s; m_Material = material; m_LightDir = lightDir; // 声明需要深度纹理:URP 会因此安排 DepthPrepass,保证此时 _CameraDepthTexture 已就绪 ConfigureInput(ScriptableRenderPassInput.Depth); } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { var desc = renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.msaaSamples = 1; desc.graphicsFormat = RenderingUtils.SupportsGraphicsFormat(GraphicsFormat.R8_UNorm, FormatUsage.Linear | FormatUsage.Render) ? GraphicsFormat.R8_UNorm : GraphicsFormat.B8G8R8A8_UNorm; RenderingUtils.ReAllocateIfNeeded(ref m_Target, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_ContactShadowTexture"); cmd.SetGlobalTexture(m_Target.name, m_Target.nameID); ConfigureTarget(m_Target); ConfigureClear(ClearFlag.None, Color.white); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { // URP 14 里 renderingData.commandBuffer 是 internal,自己的 Feature 从池里借一个 var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, m_Sampler)) { m_Material.SetVector(s_Params, new Vector4(m_Settings.length, m_Settings.steps, m_Settings.thickness, m_Settings.bias)); m_Material.SetVector(s_LightDir, new Vector4(m_LightDir.x, m_LightDir.y, m_LightDir.z, m_Settings.intensity)); // 源和目标是同一张图:Shader 根本不读 _BlitTexture,只借 Blitter 画一个全屏三角形 Blitter.BlitCameraTexture(cmd, m_Target, m_Target, m_Material, 0); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() => m_Target?.Release(); }
// 案例 23.9:接触阴影(Contact Shadows)的网页版(对应 Ch23/ContactShadowFeature.cs)。 // 在深度预渲染之后,用一个全屏 Pass 从每个像素出发、沿主光方向在屏幕空间里步进几步, // 检查有没有被深度缓冲里更靠前的表面挡住,结果写进一张 R8 的全屏纹理 _ContactShadowTexture。 // 接收物体的 Shader 再用屏幕 UV 读它,与阴影贴图的结果取最小值。 // // 网页引擎没有 URP 那样“声明 ConfigureInput(Depth) 就自动安排 DepthPrepass”的机制, // 所以这里自己先画一遍 DepthOnly,得到此时的 _CameraDepthTexture。 import { Material } from '../engine/shader.js'; import { RenderPassEvent } from '../engine/features.js'; export default class ContactShadowFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; const s = (data && data.settings) || {}; this.settings = { length: s.length ?? 0.35, steps: s.steps ?? 16, thickness: s.thickness ?? 0.15, bias: s.bias ?? 0.01, intensity: s.intensity ?? 1 }; } async init() { this.material = new Material(await this.engine.shaders.get('Cookbook/Ch23/ContactShadowPass')); } addPasses(frame, enqueue) { const L = frame.mainLight; if (!L) return; // 方向光的 forward 是光线前进方向,取反得到“指向光源”的 L const dir = [-L.forward[0], -L.forward[1], -L.forward[2]]; // 与 URP 的屏幕空间阴影相同的时机:深度预渲染之后、不透明物体之前 enqueue(RenderPassEvent.AfterRenderingPrePasses + 1, (fr) => this.execute(fr, dir)); } execute(fr, L) { const gl = fr.gl, s = this.settings, W = fr.width, H = fr.height; // 1) 深度预渲染(URP 的 DepthPrepass) const depthRT = fr.getTemporaryRT('ContactDepthPrepass', W, H, { color: 'R8', depth: 'DEPTH24' }); this.engine.bindTarget(depthRT); gl.depthMask(true); gl.colorMask(true, true, true, true); gl.clearColor(1, 1, 1, 1); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); fr.drawRenderers({ queue: 'opaque', lightModes: ['DepthOnly'] }); fr.setGlobal('_CameraDepthTexture', depthRT.depthTexture); // 2) 全屏步进,写 _ContactShadowTexture const target = fr.getTemporaryRT('_ContactShadowTexture', W, H, { color: 'R8' }); this.material.setVector('_ContactParams', [s.length, s.steps, s.thickness, s.bias]); this.material.setVector('_ContactLightDir', [L[0], L[1], L[2], s.intensity]); fr.blit(null, target, this.material, 0); fr.setGlobal('_ContactShadowTexture', target.texture); fr.bindCamera(); } }
几个要点:ConfigureInput(ScriptableRenderPassInput.Depth) 声明这个 Pass 需要深度纹理,并且事件早于不透明物体,URP 会因此安排一次深度预渲染,保证 Pass 执行时 _CameraDepthTexture 已经是本帧的深度。OnCameraSetup 里用 RenderingUtils.ReAllocateIfNeeded 按相机尺寸分配 R8 纹理并设为全局纹理。URP 14 里 RenderingData.commandBuffer 是 internal 的,自己的 Feature 要从 CommandBufferPool 借命令缓冲。主光方向从 visibleLights[mainLightIndex].localToWorldMatrix 的第三列取:方向光的 forward 是光线前进方向,取反就是“指向光源”的 L。
half Frag(Varyings input) : SV_Target { float2 uv = input.texcoord; float deviceDepth = SampleSceneDepth(uv); #if UNITY_REVERSED_Z if (deviceDepth <= 0.0) return 1; // 天空:不处理 #else if (deviceDepth >= 1.0) return 1; deviceDepth = deviceDepth * 2.0 - 1.0; #endif // 1) 由深度重建这个像素的世界坐标 float3 positionWS = ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP); int steps = (int)_ContactParams.y; float stepLen = _ContactParams.x / _ContactParams.y; float thickness = _ContactParams.z; float3 L = _ContactLightDir.xyz; // 2) 用交错梯度噪声抖动起点,把条带状走样变成细噪点(软阴影 / TAA 会再把它抹平) float jitter = InterleavedGradientNoise(uv * _ScreenSize.xy, 0); float3 p = positionWS + L * (_ContactParams.w + stepLen * jitter); half occlusion = 0; [loop] for (int i = 0; i < steps; i++) { p += L * stepLen; // 3) 把采样点投回屏幕,读那里的场景深度 float4 positionCS = TransformWorldToHClip(p); float2 sampleUV = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP); if (any(sampleUV < 0) || any(sampleUV > 1)) break; float sceneEyeDepth = LinearEyeDepth(SampleSceneDepth(sampleUV), _ZBufferParams); float sampleEyeDepth = positionCS.w; // 透视投影下 w 就是观察空间深度 float delta = sampleEyeDepth - sceneEyeDepth; // > 0:采样点在可见表面之后 // 4) 在表面之后、但又没“穿透”太深 → 光线被这块表面挡住了 if (delta > 0.002 && delta < thickness) { // 离起点越远的遮挡越弱,避免接触阴影末端出现硬边 occlusion = 1.0 - (float)i / steps; break; } } return 1.0 - occlusion * _ContactLightDir.w; }
float Frag() { vec2 uv = texcoord; float deviceDepth = SampleSceneDepth(uv); // WebGL 深度不反转:远平面是 1 if (deviceDepth >= 1.0) return 1.0; // 天空:不处理 // 1) 由深度重建这个像素的世界坐标(GLSL 版 ComputeWorldSpacePosition 内部已把 0~1 深度换成 -1~1) vec3 positionWS = ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP); int steps = int(_ContactParams.y); float stepLen = _ContactParams.x / _ContactParams.y; float thickness = _ContactParams.z; vec3 L = _ContactLightDir.xyz; // 2) 用交错梯度噪声抖动起点,把条带状走样变成细噪点(软阴影 / TAA 会再把它抹平) float jitter = InterleavedGradientNoise(uv * _ScreenParams.xy, 0); vec3 p = positionWS + L * (_ContactParams.w + stepLen * jitter); float occlusion = 0.0; for (int i = 0; i < steps; i++) { p += L * stepLen; // 3) 把采样点投回屏幕,读那里的场景深度 vec4 positionCS = TransformWorldToHClip(p); vec2 sampleUV = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP); if (any(lessThan(sampleUV, vec2(0.0))) || any(greaterThan(sampleUV, vec2(1.0)))) break; float sceneEyeDepth = LinearEyeDepth(SampleSceneDepth(sampleUV), _ZBufferParams); float sampleEyeDepth = positionCS.w; // 透视投影下 w 就是观察空间深度 float delta = sampleEyeDepth - sceneEyeDepth; // > 0:采样点在可见表面之后 // 4) 在表面之后、但又没“穿透”太深 → 光线被这块表面挡住了 if (delta > 0.002 && delta < thickness) { // 离起点越远的遮挡越弱,避免接触阴影末端出现硬边 occlusion = 1.0 - float(i) / float(steps); break; } } return 1.0 - occlusion * _ContactLightDir.w; }
步进的每一步把世界坐标投回屏幕:ComputeNormalizedDeviceCoordinates 内部处理了 UNITY_UV_STARTS_AT_TOP 的翻转,与重建世界坐标用的 ComputeWorldSpacePosition 互为逆运算。比较深度用的是观察空间线性深度:采样点的深度直接取裁剪坐标的 w(透视投影下 w 就是观察空间深度),场景深度用 LinearEyeDepth 从深度缓冲换算。“厚度”参数是对深度缓冲局限的妥协:深度缓冲只知道最前面那层表面,不知道它有多厚,采样点在表面后方太远时多半是从物体背后穿过,不应算遮挡。起点用交错梯度噪声(Interleaved Gradient Noise)抖动,把步进造成的条带变成细噪点;遮挡强度随步数衰减,避免接触阴影末端出现一道硬边。
half4 frag(Varyings i) : SV_Target { half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb; half3 n = normalize(i.normalWS); Light l = GetMainLight(TransformWorldToShadowCoord(i.positionWS), i.positionWS, half4(1, 1, 1, 1)); half contact = 1; if (_UseContact > 0.5) { float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS); contact = SAMPLE_TEXTURE2D(_ContactShadowTexture, sampler_LinearClamp, screenUV).r; } if (_ShowContact > 0.5) return half4(contact.xxx, 1); half shadow = min(l.shadowAttenuation, contact); half3 c = albedo * (l.color * saturate(dot(n, l.direction)) * shadow + SampleSH(n)); return half4(c, 1); }
vec4 frag() { vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb; vec3 n = normalize(normalWS); Light l = GetMainLight(TransformWorldToShadowCoord(positionWS), positionWS, vec4(1, 1, 1, 1)); float contact = 1.0; if (_UseContact > 0.5) { vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord); contact = texture(_ContactShadowTexture, screenUV).r; } if (_ShowContact > 0.5) return vec4(vec3(contact), 1); float shadow = min(l.shadowAttenuation, contact); vec3 c = albedo * (l.color * saturate(dot(n, l.direction)) * shadow + SampleSH(n)); return vec4(c, 1); }

局限与调参:接触阴影只能看到屏幕上可见的表面,屏幕边缘外和被遮住的物体不会投影;物体背后(深度缓冲看不到的那一面)用厚度猜测,厚度太大会出现“影子拖尾”,太小会让薄物体漏光。追踪长度一般控制在几十厘米以内,只用来补阴影贴图的近距离细节。性能上,它是每像素 N 次深度采样的全屏 Pass,步数 16~24 在桌面端开销不大;移动端可以降到 8 步,或者用半分辨率渲染再双线性放大。HDRP 内置了更完整的 Contact Shadows(还支持附加光),URP 14 没有内置,本案例提供的是一个可以直接用的起点。
23.8 选型与性能建议
| 方案 | 开销 | 适用 | 不适用 |
|---|---|---|---|
| 阴影贴图(URP 内置) | 投射物多画一到四遍 + 接收端 PCF | 通用,投到任意表面、自阴影 | 超低端机、超大量动态物体 |
| 屏幕空间阴影 | 深度预渲染 + 一次全屏 Pass | Overdraw 高、软阴影档位高的桌面/主机 | Overdraw 低的移动端(可能更慢) |
| 平面投影阴影 | 每个角色多画一遍 | 平坦地面、俯视角、要锐利角色影子 | 起伏地形、需要投到别的物体上 |
| Blob 阴影 | 一个四边形 | 大量单位、远景、低端机降级 | 需要形状正确的影子 |
| 抖动阴影 | 与普通投射相同 | 半透明物体(玻璃、薄纱、粒子) | 没有软阴影的配置 |
| 接触阴影 | 一次全屏步进 Pass | 补足近处小细节、物体落地感 | 代替阴影贴图做远距离阴影 |
无论用哪种方案,最先要做的永远是“少画”:让小物体、地面贴花、粒子关闭 Cast Shadows;用 Shadow Distance 卡住不需要阴影的远景;阴影投射用的 Shader 尽量简单(ShadowCaster Pass 不要做多余的纹理采样,没有 Alpha Clip 的物体就不要在 ShadowCaster 里采样贴图)。移动端常见的一套组合是:主光 1024~2048、1~2 级联、阴影距离 30~50 米、软阴影 Low,主角额外叠加接触阴影或平面阴影,远处单位用 Blob。与内置管线相比,URP 不再有 Shadow Projection(Close Fit / Stable Fit)选项,始终使用包围球的稳定投影;内置管线在桌面平台默认会先把方向光的级联阴影收集到一张屏幕空间纹理,URP 默认不这样做,需要时手动添加 Screen Space Shadows Feature。
本章小结
- 阴影精度取决于纹素的世界尺寸 ≈ 2r/N。多级联时每一级只有设定分辨率的一半;缩短阴影距离往往比提高分辨率更划算。
- 阴影在 (1−LastBorder)·MaxDistance 到 MaxDistance 之间淡出;接收端要用带 positionWS 的
GetMainLight重载才能得到淡出。 - Depth/Normal Bias 以纹素为单位,在投射端由
ApplyShadowBias移动顶点;URP 14 渲染阴影时还固定开启了硬件斜率偏移 (1, 2.5)。 - 软阴影 Low/Medium/High 分别是 4 点、5×5 帐篷、7×7 帐篷滤波;自写 Shader 要声明全部五个软阴影关键字。
- 平面阴影用 P′ = P − L(P.y − h)/L.y 投影顶点,模板 NotEqual + Replace 消除重叠;Blob 阴影用相乘混合的圆斑。
- 自定义阴影色在“阴影色↔灯光色”之间插值,而不是给阴影区乘色。
- 屏幕空间阴影要求自写 Shader 支持
_MAIN_LIGHT_SHADOWS_SCREEN;抖动阴影借助 PCF 把网点平均成半透明;接触阴影在屏幕空间沿光线步进,补足近距离细节。
练习
- 把平面阴影改为通用平面版本:在 C# 里用射线找到角色脚下地面的法线和高度,按 23.2 节的 4×4 矩阵每帧传入 Shader,让阴影能落在斜坡上。
- 给 Blob 阴影加上光照方向:根据主光方向把圆斑沿 −L 在地面上的投影方向偏移并拉长成椭圆,离地越高偏移越多。
- 修改
ShadowDebug.shader,在级联可视化之上叠加阴影纹素网格(用_MainLightWorldToShadow变换后的坐标乘以_MainLightShadowmapSize.zw取frac),直观看到每一级纹素的大小。 - 为接触阴影 Feature 增加“半分辨率”选项:把纹理分配为相机尺寸的一半,比较画质和耗时(用 Frame Debugger 或 Profiler 的 GPU 时间)。
- 把抖动阴影的 Bayer 矩阵换成
noise_white贴图采样(按阴影图像素坐标平铺),观察不同噪声对阴影观感的影响。