实时阴影技术演进与 URP 自定义阴影管线

行文逻辑:宏观总览 → 底层基石 → 算法进阶 → 管线工程落地 → 源码解析。

本文同时给出在 Unity 2022 LTS URP 下手写一套支持 彩色阴影、半透明阴影、PCSS 软阴影、SRP Batcher 兼容 的复合阴影管线的工程方案。


一、引言:光与影的极限博弈

阴影是构建场景空间感与画面真实感的决定性因素。一个没有正确接地阴影的物体永远像悬在空中的纸片,PBR 再炫的金属高光也救不回来。

但实时渲染中的阴影,本质是画质需求与**硬件预算(ALU、显存带宽、内存)**之间的极限平衡:

  • 想要柔和的边缘 → 多次 PCF 采样,ALU 暴涨;
  • 想要大世界覆盖 → 级联 / 虚拟阴影贴图,显存与带宽吃紧;
  • 想要物理正确 → 硬件光追,性能直接砍半。

本文将从底层 Shadow Map 出发,沿着「软阴影 → 屏幕空间 → 工程落地」的路径走完一遍,最后落在我自己用 RenderFeature 手写的 PCSS 复合管线上。


二、现代阴影技术流派全景图

五大流派一图流

实时阴影 几何与深度测试 Standard ShadowMap CSM 级联阴影 UE5 VSM 虚拟阴影贴图 Cube Map 点光源阴影 统计与滤波 VSM 方差阴影 ESM / EVSM MSM 矩阴影 屏幕空间 Contact Shadows Screen Space Shadows 物理与光追 SDF Mesh Distance Field Hardware RT Shadows 预计算混合 Lightmaps Shadowmask

横向对比表

流派 代表技术 核心优势 核心劣势 典型应用
几何与深度 CSM 工业标准,全硬件兼容 边缘走样、软化开销大 大世界主光源
UE5 VSM 极高分辨率,适配 Nanite 页表管理复杂 次世代超高多边形
统计滤波 VSM / EVSM 滤波丝滑软阴影 漏光(Light Bleeding) 风格化 / 离线烘焙
屏幕空间 Contact Shadows 极低开销解决悬浮感 仅几厘米范围 CSM 补漏工序
SSS 高精度微小几何细节 屏幕外信息丢失 头发、草丛、铁丝网
物理光追 SDF Shadows 大尺度高效软阴影 不支持蒙皮动画 远景建筑、地形
RT Shadows 物理正确,无漏光 性能与硬件门槛高 3A 电影级画质
预计算 Shadowmask 静态高质 + 动态融合 烘焙耗时、显存占用 中大型项目标配

五大流派详解

在展开技术细节之前,先用 5 段话回答一个很多新人会有的疑问:面对同一束光、同一个遮挡物,为什么要发明这么多不同算法? 答案是——每一派都在解决前一派暴露出来的问题

① 几何与深度测试派:所有流派的起点

这是阴影技术的”祖师爷”,核心逻辑只有一句话:「从光源视角渲染一遍场景,把每个像素离光源最近的距离记下来;主相机渲染时,把当前着色点变换回光源空间,深度更大的就是被挡住了」

代表算法是 1978 年 Lance Williams 提出的 Shadow Map,直到今天它依然是几乎所有实时渲染管线的基石。但它有两个原生缺陷:

  • 走样(Aliasing):ShadowMap 分辨率有限,一个纹素覆盖一片世界空间,边缘必然出现锯齿;
  • 精度(Precision):远处一张 2K² 的贴图覆盖几公里地形,平均每米只有几个纹素,根本不够用。

为了解决精度问题,CSM (Cascaded Shadow Maps) 把视锥按深度切成 2~8 段,近段高精度、远段低精度,是当今所有大世界游戏的标配。UE5 VSM (Virtual Shadow Maps) 则把虚拟纹理思想搬过来——逻辑上让 ShadowMap 拥有 16K² 分辨率,但只为屏幕上实际可见的页(Page)分配显存与计算资源,是为 Nanite 那种亿级面几何量身定制的。Cube Map 点光源阴影 则把这套机制套用到 6 个面的立方体上,每盏点光源等于渲染 6 次场景,所以引擎一般会严格限制运行时投影点光源的数量。

② 统计与滤波派:用概率论换软阴影

第一派的硬阴影是”非黑即白”的——深度比较只能产生 0 或 1。要做软阴影,最直观的办法是 PCF(Percentage Closer Filtering) 多次采样取平均,但这是 复杂度,滤波核越大越贵,4×4 = 16 次采样在移动端就开始吃力。

VSM (Variance Shadow Maps) 的革命性想法:与其问”这一点有没有被挡”,不如问”被挡的概率上界是多少”。把深度分布抽象成一个随机变量 ,通过单边切比雪夫不等式估计 的上界。这样深度图就可以用普通滤波器(高斯 / Box)预处理一次,运行时一次纹理读取就能拿到软阴影,复杂度 ——还能完美适配硬件双线性 / 三线性 / 各向异性过滤。

代价是 漏光(Light Bleeding)——当邻域内同时存在前景和背景遮挡物时,方差被夸大、概率上界变宽松,本应深阴影的区域漏出光来。EVSM 把深度先做 变换再存储,把”漏光对”在指数空间拉开;MSM (Moment Shadow Maps) 存 4 阶矩做更精确的分布重建。这一派整体偏”学院派”,在风格化、卡通、移动端定制管线有市场,但 3A 大作的工业首选仍是「第一派 + PCF」组合,因为漏光在写实场景下肉眼可辨。

③ 屏幕空间派:补漏与细节增强专家

前两派都基于”光源视角的深度图”。屏幕空间派则完全不需要光源视角,只用主相机已经渲染好的 _CameraDepthTexture 做事。

具体做法:从着色点出发,沿光源方向在屏幕空间步进(Raymarching),每一步把世界坐标投影回屏幕、采样深度图,判断当前光线是否已经被前景物体挡住。

  • Contact Shadows:射线极短(几厘米)、采样数低(4-8 次),专门修补 CSM 在物体接触地面时的漏光(Peter Panning),让物体”扎根”在场景里;
  • Screen Space Shadows (SSS):射线较长(几米)、采样数高(16-64 次),捕捉头发、草、铁丝网这类几何细节小到 ShadowMap 根本采不到的东西,是大世界游戏中近距特写画质的关键加成。

它们的共同短板是依赖屏幕信息——物体一旦移出视野范围,阴影就会突兀消失(Off-screen Artifact),所以永远只能作为「主阴影方案的补充」,而不是替代。

④ 物理与光追派:终极解,但贵

前三派本质上都是 基于光栅化的近似——用各种 hack 在屏幕或贴图上模拟”这条光线有没有被挡”。物理派直接把这件事做实:发射真实光线测试相交

  • SDF (Mesh Distance Field) Shadows:离线把场景几何编码成 3D 距离场纹理,运行时用 Sphere Tracing 计算软阴影。无 ShadowMap 走样、远距离软阴影质量极佳,性能比硬件光追便宜得多。短板是形变动画支持差——蒙皮骨骼的每个 Pose 都重新生成 SDF 不现实,所以仅适用于静态场景。UE5 Lumen 的远场阴影就是基于这套机制;
  • Hardware RT Shadows:调 GPU 的 RT Core 发射光线,根据面光源大小天然得到物理正确的软阴影——光源越大、距离越远,阴影越软;完全无漏光、无走样。代价是性能开销巨大、需要 RTX 30+ / RX 6000+ / Apple M3+ 等支持光追的硬件,且 BVH 维护本身也是一笔不菲的运行时开销。这是 3A 厂商「能开就开,开不起就 fallback CSM」的方案。

⑤ 预计算混合派:拿空间换时间

终极的「移动端 / 主机优化」流派。核心思路是把光照与阴影计算搬到离线,运行时只做查表。

  • Lightmaps:直接把光照 + 阴影烘焙到纹理上,运行时零计算开销,可在最低端硬件上跑出主机级画质。但缺点也很硬——完全不支持动态物体和动态光源,所有可移动的角色 / 道具都得用 Light Probe 单独打光;
  • Shadowmask:是 Lightmap 的进化版——贴图里只存「阴影遮挡系数」(一个 0~1 的值,不是最终颜色)。当动态角色走到烘焙好的阴影区域时,引擎会拿这个系数与实时阴影做加权融合,避免动态阴影 + 静态阴影叠加导致的”双重阴影”穿帮。这是当前中大型项目兼顾性能与表现的事实标准(Unity Mixed Lighting 的 Shadowmask 模式即此)。

工业界主流复合策略

CSM 主力 + SSS 补细节 + Contact Shadows 接地缝合

这一组合至今仍是绝大多数项目的最优解,本文第六、七章手写实现的彩色 ShadowMap 管线本质上就是这条路线的「定制化扩展」。


三、阴影映射的底层基石与痛点规避

双 Pass 机制

ShadowMap 的核心思想:把光源当成相机渲染一遍场景,记录每条光线最先击中的那个点的深度。 主相机渲染时再把着色点变换回光源空间,与该深度比较。

sequenceDiagram
    participant L as 光源相机
    participant SM as ShadowMap (Depth RT)
    participant MC as 主相机
    participant FS as Fragment Shader

    L->>SM: Pass 1 - 光源视角光栅化, 写入深度 d_q
    Note over SM: 仅 ColorMask 0, 只写 ZBuffer
    MC->>FS: Pass 2 - 主视角光栅化
    FS->>FS: 顶点 worldPos × 光源 V × P → shadowCoord
    FS->>SM: 用 shadowCoord.xy 采样 d_q
    FS->>FS: 比较 currentDepth (d_p) vs d_q
    alt d_p > d_q + bias
        FS->>FS: 在阴影中, 衰减光照
    else
        FS->>FS: 完全照亮
    end

经典视觉瑕疵

1. 阴影痤疮 Shadow Acne

ShadowMap 分辨率有限,一个纹素覆盖斜面上的一片区域,该区域内的所有片段都拿同一个深度做比较。当光线斜着射入表面时,部分片段的深度会略大于纹素记录的深度,被错误地判定为「自遮挡」,呈现条纹噪点。

2. 彼得潘现象 Peter Panning

为修复痤疮强行拉大 bias,会让阴影远离投射者,物体看起来「悬浮」在地面上、和影子断了连接。

Shadow Acne 与 Peter Panning 对比

📷 配图建议:三连对比 GIF——左:无 bias 的条纹痤疮;中:bias 过大的 Peter Panning 悬浮感;右:自适应 bias 修复后的干净接地效果。
资源未上传时此处占位。

自适应 Shadow Bias

Bias 应当随表面入射角和 ShadowMap 像素覆盖范围动态变化:

1
2
3
4
5
6
7
8
float getShadowBias(float c, float filterRadiusUV)
{
vec3 N = normalize(vNormal);
vec3 L = normalize(uLightPos - vFragPos);
float fragSize = (1.0 + ceil(filterRadiusUV)) *
(FRUSTUM_SIZE / SHADOW_MAP_SIZE / 2.0);
return max(fragSize, fragSize * (1.0 - dot(N, L))) * c;
}

要点A 把视锥体大小折算到一个 ShadowMap 纹素覆盖的世界距离;B 在掠射角下放大 bias。filterRadiusUV 给 PCF 留接口——滤波半径越大,邻域内可能落入的纹素越多,需要的 bias 越大。

bias 还有 法线偏移(Normal Bias) 的变体——直接把世界坐标沿法线推一小段后再投影,对斜面友好;我的 ColoredShadowCaster Pass 就是这么做的:

1
2
3
worldPos += worldNormal * _ColoredShadowNormalBias * 0.01;
output.shadowCoord = mul(_ColoredShadowMatrix, float4(worldPos, 1.0));
output.shadowCoord.z += _ColoredShadowBias * 0.01;

四、迈向真实:软阴影的数学之美

4.1 PCF:从硬阴影到模糊边缘

PCF(Percentage Closer Filtering) 不是对深度图做模糊(这是错的,下面 VSM 章节会解释为什么),而是把”深度比较”这个二值结果做平均

1
2
3
4
5
6
7
float visibility = 0.0;
for (int i = 0; i < N; i++) {
float2 uv = shadowUV + offset[i] * radius;
float d = SAMPLE_TEXTURE2D(_DepthShadowMap, s, uv).r;
visibility += step(currentDepth, d + bias);
}
visibility /= N;

采样范围越大、采样数越多,阴影越软,但 ALU 也越贵。我在 SampleSoftShadowPCF 中按 sampleCount 分了 2×2 与 3×3 两档,避免低端机跑满 9 次采样。

4.2 Poisson 盘采样:抗规则噪点

3×3 网格采样会产生明显的方格走样。换成 Poisson 盘 的预定义伪随机采样点,能在相同采样数下显著提高视觉质量:

1
2
3
4
5
6
7
8
9
10
11
12
13
static const float2 PoissonDisk[16] = {
float2(-0.94201624, -0.39906216),
float2( 0.94558609, -0.76890725),
/* ... 共 16 个均匀分散点 ... */
};

void SamplePoissonDiskShadow(/*...*/) {
float2x2 rotMatrix = GetRotationMatrix(rotation); // 每像素旋转去 banding
for (int i = 0; i < sampleCount; i++) {
float2 offset = mul(rotMatrix, PoissonDisk[i]) * radius;
// ... 采样 + 比较 + 累加
}
}

每像素旋转是关键:相邻像素若使用同一组采样点,会暴露出 Poisson 盘本身的”花纹”。GetRotationMatrix(rotation) 接收一个伪随机角度,按像素打散这种 pattern。

4.3 PCSS:物理近似的”近实远虚”

真实阴影满足:遮挡物离接收面越远,半影越大。PCSS(Percentage Closer Soft Shadows,Fernando 2005)用相似三角形把这件事用 ShadowMap 模拟出来。

flowchart LR
    A[Shading Point] --> B[Step 1: Blocker Search
FindBlockerDistance] B -->|无遮挡| Z[直接返回 1.0] B -->|平均遮挡深度 d_blocker| C[Step 2: Penumbra Estimation
相似三角形] C --> D[Step 3: PCF / Poisson Filtering
动态半径] D --> E[最终 visibility]

Step 1 · 遮挡物搜索

在以 shading point 为中心的一个 UV 圆盘内随机采样深度图,统计比 receiver 浅的点的平均深度。搜索半径与光源大小、接收点深度成正比:

1
2
3
4
5
6
7
8
9
10
float depthFactor  = currentDepth;             // 越远的点搜索域越大
float baseRadius = lightSize * depthFactor;
float searchRadius = min(baseRadius, maxAllowedRadius);

for (int i = 0; i < numSamples; i++) {
float2 sampleUV = shadowUV + PoissonDisk[i] * searchRadius;
float d = SAMPLE_TEXTURE2D(_DepthShadowMap, s, sampleUV).r;
if (d < currentDepth) { blockerSum += d; numBlockers++; }
}
return numBlockers > 0 ? blockerSum / numBlockers : -1.0;

边界处理用 uvToBorder 限幅,防止搜索域跑到 ShadowMap 外面采到 (0, 0) 黑边。

Step 2 · 半影尺寸估计

1
2
float penumbra = lightSize * (receiverDistance - blockerDistance) / blockerDistance;
penumbra *= pow(_PCSSPenumbraScale, 4); // 高次幂便于精细调参

NVIDIA 白皮书的实现额外乘 NEAR_PLANE / coords.z,把 light size 投影到 ShadowMap 平面。这一步对正交投影的方向光意义不大(深度均匀),所以我在自己的实现里省略了,结果同样可控。

Step 3 · 自适应 PCF 滤波

把第二步算出的 penumbraSize 喂回 PCF 或 Poisson 采样作为滤波半径——远处遮挡物自动得到更大的模糊核,”近实远虚” 自然涌现。

PCSS 近实远虚效果

📷 配图建议:一根长投影物(电线杆 / 旗杆 / 树干)的特写——靠近根部阴影边缘锐利,远端逐渐柔化弥散。最能直观展示 PCSS 相对于固定半径 PCF 的优势。

4.4 VSM 与切比雪夫不等式

为什么 PCF 慢,VSM 快?

PCF 必须先采样、再比较、再平均,比较是非线性操作(step),所以不能预先对深度图做高斯模糊 —— 模糊后的深度无法表达”该纹素附近遮挡的概率分布”。

VSM 的天才之处:改变阴影计算的物理意义。它不再问”我被遮挡了吗?”,而是问”我被遮挡的概率上界是多少?

数学基础:单边切比雪夫不等式

只要知道期望 方差 ,就能估计「随机变量超过 的概率上界」。

为什么要存

  • 在 ShadowMap 上做 Box / Gaussian 滤波 → 得到该纹素邻域内深度的期望
  • 同时存 通道、做相同滤波 → 得到
  • 由方差公式 ,得到方差。

VSM 完整流程

flowchart TD
    A[Pass 1: 光源视角渲染
RT 输出 d 与 d²] --> B[对该 RT 做 Box / Gaussian 滤波] B --> C[Pass 2: 主相机视角着色] C --> D{currentDepth t 与 E_d 比较} D -->|t ≤ E_d| E[完全照亮 visibility = 1] D -->|t > E_d| F[切比雪夫估算 P_max] F --> G[visibility = 1 - P_max]

漏光(Light Bleeding)

VSM 的命门:当一个区域内同时存在前景遮挡物远处背景时,方差 被「拉大」,切比雪夫上界变宽松,原本应当被深阴影覆盖的区域漏出光。

工业上用 EVSM(Exponential VSM) 把深度先 再存储+滤波,把”漏光对”在指数空间拉开;或用 MSM(存 4 阶矩)数学上压制,但带宽与精度成本上升。


五、屏幕空间技术:微观细节的极致压榨

5.1 共同的底层:屏幕空间向光步进

Contact Shadows 与 Screen Space Shadows 底层算法几乎一样——从着色点出发,沿光源方向在屏幕深度缓冲里做 Raymarching,每一步采样 _CameraDepthTexture 检查是否被前景遮挡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
float CalculateScreenSpaceRaymarchingShadow(
float3 worldPos, float3 lightDir,
float maxRayLength, int maxSteps, float thickness)
{
float stepSize = maxRayLength / (float)maxSteps;
float3 rayPos = worldPos + lightDir * 0.05; // 自遮挡偏移
float visibility = 1.0;

[loop]
for (int i = 0; i < maxSteps; i++) {
rayPos += lightDir * stepSize;

float4 clip = mul(float4(rayPos, 1), VP);
float3 ndc = clip.xyz / clip.w;
float2 uv = ndc.xy * 0.5 + 0.5;
if (any(uv < 0) || any(uv > 1)) break;

float sceneZ = LinearizeDepth(SceneDepth.SampleLevel(s, uv, 0));
float rayZ = LinearizeDepth(ndc.z);
if (rayZ > sceneZ && abs(rayZ - sceneZ) < thickness) {
visibility = 0.0; break;
}
}
return visibility;
}

5.2 同算法、不同参数 → 不同用途

维度 Contact Shadows Screen Space Shadows
设计目的 修补 Peter Panning,让物体”扎根” 高精度近距全局阴影、补 SM 精度
射线长度 极短(几厘米) 长(几米甚至更远)
步进次数 4–8 16–64
性能开销 极低,cache 命中率高 高,跨像素带宽压力大
屏幕外伪影 几乎无(距离太短) 明显(边缘物体阴影闪烁)

5.3 URP 官方 Screen Space Shadows 解读

URP 的 Hidden/Universal Render Pipeline/ScreenSpaceShadows 走的是全屏 Pass:用相机深度反推世界坐标,调 MainLightRealtimeShadow(coords),把 CSM 的采样结果烘到屏幕空间一张 RT 上,后续 ForwardLit 直接读这张 RT,避免每个 fragment 重复采样级联

1
2
3
4
float deviceDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, s, uv).r;
float3 wpos = ComputeWorldSpacePosition(uv, deviceDepth, unity_MatrixInvVP);
float4 coords = TransformWorldToShadowCoord(wpos);
return MainLightRealtimeShadow(coords);

这是「屏幕空间阴影」一词的另一层含义——阴影计算结果的屏幕空间缓存,不是 raymarching。

5.4 二次元风格化:透视空间钳制阈值

二次元角色渲染常用非标准化的轻量 SSS,思路是「用一个视图空间偏移采样深度图差值」,配合透视因子在 NDC 空间钳制阈值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float linearEyeDepth = input.positionCS.w;
float perspective = 1.0 / linearEyeDepth; // 透视因子
float offsetMul = _ScreenSpaceShadowWidth * 5.0 * perspective / 100.0;

float3 lightDirVS = TransformWorldToViewDir(lightDirectionWS);
float2 offset = lightDirVS.xy * offsetMul;

int2 coord = clamp(input.positionCS.xy + offset * _ScaledScreenParams.xy,
0, _ScaledScreenParams.xy - 1);
float offsetSceneLinearDepth = LinearEyeDepth(LoadSceneDepth(coord), _ZBufferParams);

float fadeout = max(1e-5, _ScreenSpaceShadowFadeout);
float attenuation = saturate((offsetSceneLinearDepth -
(linearEyeDepth - _ScreenSpaceShadowThreshold)) * 50 / fadeout);

只采一次(不步进)、用透视因子让远近偏移视觉一致——表现力够用,性能比真 raymarching 便宜一个数量级。


六、URP 管线深度定制:彩色与半透明阴影工程落地

6.1 为什么要自己写一套?

Unity 默认 ShadowCaster Pass ColorMask 0只写深度,不写颜色。这在以下场景下是硬伤:

  • 彩色玻璃 / 教堂窗户:阴影应该带颜色;
  • 半透明 Alpha 阴影:默认要么不投,要么 Dither 抖动;
  • 风格化二次元:希望阴影色调可调(暖色阴影、青色阴影)。

解决方案:手写一个 RenderFeature,输出彩色 RT + 深度 RT,在主 Shader 里采样。

6.2 整体架构

flowchart TB
    subgraph CPU [C# - ScriptableRendererFeature]
        A[Create
Init RTHandles, 注入 BeforeRenderingShadows] B[AddRenderPasses
过滤主相机, 主光源 = Directional] C[Setup
计算 LightView × Projection
SetGlobalMatrix _ColoredShadowMatrix] end subgraph GPU [GPU - ColoredShadowCaster Pass] D[Configure
双 RT: ARGB32 + RFloat
ConfigureTarget MRT] E[Execute
SetView/Projection
DrawRenderers Opaque + Transparent] F[Fragment
SV_Target0: 颜色 × _ShadowColor
SV_Target1: linearDepth] end subgraph FWD [GPU - ForwardLit Pass] G[采样 _ColoredShadowMap
+ _DepthShadowMap] H[SampleCustomShadowMap
支持 Hard / PCF / Poisson / PCSS] I[CalculatePBRLighting
shadowColor 参与能量守恒] end A --> B --> C --> D --> E --> F --> G --> H --> I

6.3 矩阵推导:手写 LightView × Projection

不同光源类型的光源空间投影方式天然不同——本文聚焦平行光,但要让架构具备扩展性,三类光源的处理思路都得理清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Matrix4x4 GetProjectionMatrix() {
if (m_MainLight.lightType == LightType.Directional)
{
// 平行光:正交投影。所有"光线"平行,无透视收敛。
return Matrix4x4.Ortho(OrthoLeft, OrthoRight, OrthoBottom, OrthoTop, OrthoNear, OrthoFar);
}
else if (m_MainLight.lightType == LightType.Spot)
{
// 聚光灯:透视投影。FOV 取 spotAngle,锥体外的部分被自动裁剪。
float spotAngle = m_MainLight.spotAngle;
return Matrix4x4.Perspective(spotAngle, 1.0f, 0.1f, m_MainLight.range);
}
else if (m_MainLight.lightType == LightType.Point)
{
// 点光源:Cube Map 6 面(每面 FOV=90°),或 Dual-Paraboloid 双张抛物面贴图。
// 此处仅返回单面占位,真实实现需要 6 次 Pass 循环切换 V 矩阵。
return Matrix4x4.Perspective(90.0f, 1.0f, 0.1f, m_MainLight.range);
}
throw new NotImplementedException();
}

三类光源处理方式对比:

光源类型 投影矩阵 Pass 次数 阴影贴图形态
Directional 正交 (Matrix4x4.Ortho) 1(CSM 时按级联次数 N) 2D RT
Spot 透视 (Matrix4x4.Perspective),FOV = spotAngle 1 2D RT
Point 透视,FOV = 90°,6 面 6 Cube RT (TextureCube)

点光源的工程取舍:6 次 Pass 在低端设备上是灾难,所以另一条路是 Dual-Paraboloid Mapping (DPSM)——把球面贴图压缩到 2 张抛物面贴图上(前 / 后半球各一张),只需 2 次 Pass。代价是边缘扭曲、采样不均,因此它适合非主光源、近距离的辅助点光源(如室内灯泡、火把),主光源仍然走 Cube Map。

本文的 RenderFeature 默认仅处理平行光(m_MainLight.lightType != LightType.Directional 时直接 return 跳过整个 Pass),聚光灯与点光源是后续扩展点——只需把 GetProjectionMatrix 的分支逻辑接通、把 Configure 阶段的 RT 类型按需换成 TextureCube,复用整套 MRT + 双 RT 框架即可。

1
2
3
4
5
6
7
8
9
private Matrix4x4 GetLightViewMatrix() {
Matrix4x4 zFlip = Matrix4x4.identity;
zFlip.m22 *= -1f; // OpenGL → DX 深度方向
return zFlip * m_MainLight.localToWorldMatrix.inverse;
}

// 把 V × P 一起塞到全局变量
m_ShadowMatrix = GetProjectionMatrix() * GetLightViewMatrix();
Shader.SetGlobalMatrix("_ColoredShadowMatrix", m_ShadowMatrix);

z 轴翻转放在 C# 端而非 Shader 内:Shader 拿到的就是干净的 mul(_ColoredShadowMatrix, worldPos),可读性、可调试性都提升一档;同时省了每帧每像素一次 z 翻转的 ALU。

6.4 多渲染目标 MRT:一次 Pass 写两张 RT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor desc) {
var colorDesc = new RenderTextureDescriptor(res, res, RenderTextureFormat.ARGB32, 24);
var depthDesc = new RenderTextureDescriptor(res, res, RenderTextureFormat.RFloat, 0);

cmd.GetTemporaryRT(m_ColoredShadowMap.id, colorDesc, FilterMode.Point);
cmd.GetTemporaryRT(m_DepthShadowMap.id, depthDesc, FilterMode.Point);

ConfigureTarget(
new RenderTargetIdentifier[] {
m_ColoredShadowMap.Identifier(), // SV_Target0 颜色
m_DepthShadowMap.Identifier() // SV_Target1 线性深度
},
m_ColoredShadowMap.Identifier() // 共用 ZBuffer
);
ConfigureClear(ClearFlag.All, Color.white);
}

Fragment 端配套两个 SV_Target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct FragmentOutput {
float4 colorTarget : SV_Target0;
float4 depthTarget : SV_Target1;
};

FragmentOutput fragShadow(Varyings input, bool facing : SV_IsFrontFace) {
/* ... 计算 color, linearDepth, NdotL ... */
#if defined(_SURFACE_TYPE_TRANSPARENT)
output.colorTarget = float4(color.rgb * _ShadowColor.rgb, NdotL * _ShadowColor.a);
#else
output.colorTarget = float4(color.rgb * _ShadowColor.rgb, 1.0);
#endif
output.depthTarget = float4(linearDepth.xxx, 1.0);
return output;
}

6.5 注入时机:BeforeRenderingShadows

1
m_ColoredShadowPass.renderPassEvent = RenderPassEvent.BeforeRenderingShadows;

赶在 URP 内部 Shadow / Opaque / Transparent 队列之前生成,确保后续任何材质都能通过 _ColoredShadowMap / _DepthShadowMap 全局纹理读取到。

6.6 Profiling 接入:让 Frame Debugger 层级分明

1
2
3
4
5
6
7
CommandBuffer cmd = CommandBufferPool.Get(k_RenderColoredShadowMapTag);
using (new ProfilingScope(cmd, new ProfilingSampler(k_RenderColoredShadowMapTag)))
{
/* ... 渲染逻辑 ... */
context.ExecuteCommandBuffer(cmd);
}
CommandBufferPool.Release(cmd);

ProfilingScope + CommandBufferPool 是 URP 自定义 Pass 的标准接入范式

  • Frame Debugger 中显示为独立分组节点;
  • Unity Profiler 自动记录 GPU 毫秒级耗时;
  • CommandBufferPool 复用 CommandBuffer,避免每帧 GC。

6.7 性能兜底:Dither 抖动半透明阴影

不是所有平台都吃得下 ARGB32 + RFloat 双 RT。移动端可用 Dither + AlphaClip 退化方案:根据屏幕坐标 + alpha 查 _DitherMaskLOD 3D 抖动表,决定该像素是否丢弃,让深度图本身呈现”半透明”分布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#if defined(_USEOPTIMIZEDDITHER_ON)
// Blue Noise + 多采样平均
float2 hash = frac(sin(dot(screenPos, float2(12.9898, 78.233))) *
float2(43758.5453, 28001.8384));
float2 ditherCoord = fmod(screenPos + hash * 2.0, 4.0) * 0.25;
half a1 = SAMPLE_TEXTURE3D(_DitherMaskLOD, s, float3(ditherCoord, alpha * 0.9375)).a;
half a2 = SAMPLE_TEXTURE3D(_DitherMaskLOD, s, float3(ditherCoord + 0.5, alpha * 0.9375)).a;
alphaRef = (a1 + a2) * 0.5;
#else
// 传统 Bayer
float2 ditherCoord = fmod(screenPos, 4.0) * 0.25;
alphaRef = SAMPLE_TEXTURE3D(_DitherMaskLOD, s, float3(ditherCoord, alpha * 0.9375)).a;
#endif
clip(alphaRef - 0.01);

Blue Noise 比 Bayer 在低频区域分布更均匀,配合双采样平均,能在 4×4 抖动核下做出接近真半透明的视觉效果,几乎零 ALU 开销。

6.8 Unity 6 与 Render Graph:迁移路径前瞻

本文的 ColoredShadowMapPass 走的是 传统 ScriptableRendererFeature 路线(Unity 2022 LTS / URP 14):手动 cmd.GetTemporaryRT 申请 RT、ConfigureTarget 绑定、FrameCleanup 释放,资源生命周期完全由开发者把控。这条路在 Unity 6(URP 17+)已经进入维护模式——Render Graph API 才是新的官方推荐。

范式转变:过程式 → 声明式

Render Graph 的核心变化是把”过程式的命令记录”改成声明式的图编排:你不再”立刻申请一张 RT 并往里写”,而是”声明这个 Pass 需要这些资源、产出哪些资源”,框架自己分析整张图的依赖来调度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Render Graph 风格的伪代码示意
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
// 1. 声明资源描述(不立即分配)
var colorDesc = new TextureDesc(res, res) {
format = GraphicsFormat.R8G8B8A8_UNorm, name = "_ColoredShadowMap" };
var depthDesc = new TextureDesc(res, res) {
format = GraphicsFormat.R32_SFloat, name = "_DepthShadowMap" };

using (var builder = renderGraph.AddRasterRenderPass<PassData>(
"ColoredShadow", out var data))
{
// 2. 声明 Pass 的输入 / 输出
data.colorRT = builder.UseTextureFragment(
renderGraph.CreateTexture(colorDesc), 0);
data.depthRT = builder.UseTextureFragment(
renderGraph.CreateTexture(depthDesc), 1);

// 3. 注册执行回调(真正的 GPU 命令在这里)
builder.SetRenderFunc((PassData d, RasterGraphContext ctx) => {
/* DrawRenderers 等渲染逻辑 */
});
}
}

Render Graph 带来的三个核心优化

  1. 资源生命周期自动管理:Render Graph 分析整张图后,只在真正需要的时间窗口分配显存。阴影 RT 只在 Shadow Pass → ForwardLit Pass 之间存活,自动释放,无需手动 ReleaseTemporaryRT,也杜绝了忘释放导致的显存泄漏;
  2. Memoryless RT 与 Tile Memory 优化:在 Apple Silicon / Adreno / Mali 等 TBDR (Tile-Based Deferred Rendering) GPU 上,Render Graph 能识别”该 RT 仅在同一 Pass 内被读写”,直接放在片上 Tile Memory,完全不写回 DRAM——这对移动端阴影管线带宽优化是降维打击;
  3. 资源别名(Aliasing):多个生命周期不重叠的 RT 可复用同一块物理显存,显存峰值大幅下降,对开 4K + HDR + 多 Pass 的项目是刚需。

迁移要点总结

把当前 RenderFeature 代码搬到 Render Graph 的对照表:

Legacy API(本文方案) Render Graph 对应
cmd.GetTemporaryRT renderGraph.CreateTexture(TextureDesc)
ConfigureTarget (MRT) builder.UseTextureFragment(rt, slotIdx)
Execute() 内 CommandBuffer builder.SetRenderFunc((data, ctx) => …)
FrameCleanup 不需要(自动)
Shader.SetGlobalMatrix builder.SetGlobalTextureAfterPass 或常规 SetGlobal

Compatibility Mode 的去留:Unity 6.0 仍提供 Render Graph Compatibility Mode 让旧 RenderFeature API 跑起来,但 6.1 起默认关闭,6.2 起预计完全移除。如果项目现在还在 URP 14(Unity 2022 LTS),本文方案可直接落地;若已切到 URP 17+(Unity 6),建议一步到位用 Render Graph 重写——语义不变,但显存与带宽效率显著提升,对移动端项目尤为关键。


彩色玻璃投射阴影最终效果

📷 配图建议:本文方案的最终成果展示——一束阳光透过彩色玻璃窗(红 / 蓝 / 黄三色),在地板上投射出对应色调的阴影光斑,配合 PCSS 远处柔化效果。这是「彩色阴影 + 半透明 Alpha + PCSS 软阴影」三大特性同框的视觉高潮。


七、PCSS 复合软阴影 Shader 架构

7.1 Uber Shader 宏定义策略

我把所有阴影策略压在同一个 Shader 里,用 shader_feature_local 控制变体:

1
2
3
4
5
6
7
#pragma shader_feature_local _USE_POISSON_SAMPLING
#pragma shader_feature_local _USE_DISTANCE_BASED_SHADOW
#pragma shader_feature_local _USE_PCSS

#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _SHADOWS_SOFT
  • shader_feature_local → 项目内只编译实际用到的组合,避免变体爆炸;
  • multi_compile → URP 全局阴影宏,必须保留以兼容内置管线流转。

7.2 调度入口:SampleCustomShadowMap

一个函数把所有路径串起来,宏决定走哪条:

GLSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void SampleCustomShadowMap(float4 shadowCoord, float3 worldPos,
out float4 shadowColor, out float shadowAttenuation)
{
float3 projCoords = shadowCoord.xyz / shadowCoord.w;
projCoords.xy = projCoords.xy * 0.5 + 0.5;
projCoords.z = projCoords.z * 0.5 + 0.5;

shadowAttenuation = 1.0;
shadowColor = float4(1, 1, 1, 0);
if (any(projCoords < 0) || any(projCoords > 1)) return; // 视锥外完全照亮

#ifdef _SHADOWS_SOFT
#ifdef _USE_PCSS
SamplePCSSShadow(projCoords.xy, projCoords.z, shadowColor, shadowAttenuation);
#else
float r = _SoftShadowRadius;
#ifdef _USE_DISTANCE_BASED_SHADOW
r *= CalculateDistanceBasedSoftness(worldPos, shadowCoord);
#endif
#ifdef _USE_POISSON_SAMPLING
SamplePoissonDiskShadow(projCoords.xy, projCoords.z,
_PoissonSampleCount, r, _PoissonRotation,
shadowColor, shadowAttenuation);
#else
SampleSoftShadowPCF(projCoords.xy, projCoords.z,
_SoftShadowSamples, r, shadowColor, shadowAttenuation);
#endif
#endif
#else
// 硬阴影:直接 step 深度比较
shadowColor = SAMPLE_TEXTURE2D(_ColoredShadowMap, s_color, projCoords.xy);
float shadowDepth = SAMPLE_TEXTURE2D(_DepthShadowMap, s_depth, projCoords.xy).r;
shadowAttenuation = step(projCoords.z, shadowDepth + _ColoredShadowBias);
#endif
}

7.3 与 Cook-Torrance BRDF 的能量守恒整合

阴影颜色不能简单 lerp 到漫反射结果之后,那样会破坏间接光与高光的能量平衡。正确做法是调制 radiance 项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float3 CalculatePBRLighting(Light light, float3 N, float3 V, float3 albedo,
float metallic, float roughness, float3 F0,
float3 shadowColor, float shadowAttenuation)
{
/* ... GGX D, Smith G, Schlick F, kS / kD 能量守恒 ... */
float3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 1e-3);
float3 diffuse = (albedo / PI) * kD;

// 关键:阴影颜色作用在光源 radiance 上, 而不是最终 color 上
float3 radiance = light.color * light.distanceAttenuation;
radiance *= lerp(shadowColor, float3(1, 1, 1), shadowAttenuation);

return (diffuse + specular) * radiance * NdotL;
}

物理含义shadowAttenuation = 1 时光源完全照射;= 0 时光源被染色透明物体过滤,剩下的 radiance 是 shadowColor。这样彩色玻璃投出的有色光能正常驱动 BRDF 的 specular / diffuse 分项,而非”事后涂色”。

7.4 SRP Batcher 兼容性

这是最容易踩坑的一点。SRP Batcher 要求所有 per-material 属性集中在同一个 CBUFFER:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
CBUFFER_START(UnityPrtMaterial)
float4 _BaseColor; float4 _BaseMap_ST;
float _Metallic; float4 _MetallicMap_ST;
float _Smoothness; float4 _SmoothnessMap_ST;
float4 _NormalMap_ST; float _NormalScale;
float4 _OcclusionMap_ST; float _OcclusionStrength;
float4 _EmissionColor; float4 _EmissionMap_ST;

float _RadianceStrength; float _IrradianceStrength;
float _IndirectRatio;
float _Surface; float _Cutoff;

// 阴影相关属性同样要进 CBUFFER
float _SoftShadowRadius; float _SoftShadowSamples;
float _UsePoissonSampling; float _PoissonSampleCount;
float _PoissonRadius; float _PoissonRotation;
float _UseDistanceBasedShadow;
float _MinShadowDistance; float _MaxShadowDistance;
float _MinShadowSoftness; float _MaxShadowSoftness;
float _DistanceFalloffPower;
float _UsePCSS; float _PCSSLightSize;
float _PCSSPenumbraScale; float _PCSSBlockerSamples;
CBUFFER_END

// 全局参数(来自 RenderFeature)放在 CBUFFER 之外
float4x4 _ColoredShadowMatrix;
float _ColoredShadowBias;
float _ColoredShadowNormalBias;

踩坑要点

  1. 任何 Material.SetXxx 设置的属性,必须出现在 CBUFFER 内,否则 SRP Batcher 直接 break 这个材质,整个场景 Draw Call 暴增;
  2. Shader.SetGlobalXxx 设置的全局参数反而不能进 CBUFFER,否则也会 break;
  3. Frame Debugger 中观察 SRP Batcher 状态:”SRP Batcher = compatible” 才算通过。

八、结语:性能与表现的永恒平衡

本文方案适用边界

项目体量 推荐组合
移动端轻量 Unity 内置 CSM + Dither 半透明
PC 中量级 本文方案:彩色 RT + PCSS + Distance-Based Softness
PC / 主机 3A URP CSM + 硬件 RT Shadows + Contact Shadows 缝合
风格化二次元 本文方案 + 透视空间 SSS + Ramp 阴影色

未来技术展望

  • 硬件光追的降维打击:当 RTX 50 / Apple M5 系列普及后,传统 Rasterization Shadow 的所有 hack(bias、级联、漏光修补)都会被一组 TraceRayInline() 直接抹平。
  • SDF / Lumen / Nanite 生态:UE5 已经走出了距离场 + 虚拟化几何 + 屏幕空间探针的全新链路,Unity 这边 HDRP 的 APV(Adaptive Probe Volumes)也在追赶。
  • AI 降噪:DLSS Ray Reconstruction 把 1 spp 的光追阴影补到 4K 画质,阴影开销和「能不能开光追」之间的鸿沟正在被 AI 填平。

但无论硬件怎么迭代,在指定预算下做最像物理正确的视觉这件事的本质不会变。理解 Shadow Map 双 Pass、切比雪夫、PCSS 三步曲都是计算机图形学中很有价值的事情。