Banner

「Custom SRP」:阴影系统

系列第 5 篇。承接 Note 4 的光照框架——光源的 light.attenuation 字段中的阴影分量正是本篇要回答的”光是如何被遮挡的”。本篇是整个系列技术密度最高的一篇:CSM、PCF、点光阴影、Shadowmask 烘焙混合,每一项都涉及 CPU 端数据组织、Atlas 资源管理、Shader 端采样链路三个层面的协同。整体内容按”方向光 → 其他光 → 烘焙混合”的从主到次顺序展开。

TL;DR

  • 双 Atlas 架构:方向光独占一张 Atlas(CSM 占多 Tile),点光与聚光共享另一张 Atlas(每点光占 6 个面、每聚光占 1 个面)。Render Graph 用 TextureDesc { isShadowMap = true } 声明,省去 stencil buffer 分配。
  • CSM 是空间 LoD:相机近处用高分辨率近 cascade、远处用低分辨率远 cascade,本质是用屏幕空间分布替代固定分辨率,对抗透视投影下的远近精度需求差。
  • PCF 三档统一:Low(3×3) / Medium(5×5) / High(7×7) 全局质量等级,覆盖方向光与其他光。Shader 关键字从 4×4=16 种排列收敛为 3 种,编译时间下降 80%+。
  • 阴影伪影修复三件套:Depth Bias 防 Acne、Normal Bias 防 Light Bleeding、Slope Scale Bias 自适应入射角。
  • Shadowmask 双通道:每 texel 4 通道支持最多 4 重叠光源;Shadowmask 模式只远端用烘焙、Distance Shadowmask 全程用实时 + 远端 fallback 烘焙。
  • Unity 6 阴影剔除并行化LightShadowCasterCullingInfo 把”剔除-绘制”串行链路改为”剔除-剔除-…-绘制-绘制-…”并行调度,多光源场景 CPU 阴影耗时显著下降。

1. 阴影系统全景

1.1 整体数据流

flowchart TD
    A[CullingResults.visibleLights] --> B[Shadows.ReserveDirectionalShadows
分配 Atlas Tile + 计算阴影矩阵] A --> C[Shadows.ReserveOtherShadows
分配 Atlas Tile + 处理点光6面] B --> D[ShadowedDirectionalLight[]
NativeArray] C --> E[ShadowedOtherLight[]
NativeArray] D --> F[ShadowsPass · BuildRendererList
每光源·每Tile 一个 RendererList] E --> F F --> G[Render Graph 调度
多 List 并行剔除] G --> H[ShadowCaster Pass 绘制
写入 Atlas Tile] H --> I[_DirectionalShadowAtlas
R32_FLOAT depth-only] H --> J[_OtherShadowAtlas
R32_FLOAT depth-only] I --> K[Lit Shader · 采样阶段] J --> K L[unity_ShadowMask
烘焙 4 通道 RGBA] --> K M[unity_ProbesOcclusion
动态对象用] --> K K --> N[light.attenuation
送回 Note 4 BRDF] style F fill:#fff3e0,stroke:#f57c00,stroke-width:2px style G fill:#fff3e0,stroke:#f57c00,stroke-width:2px style K fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style N fill:#e8f5e9,stroke:#388e3c,stroke-width:2px

整个流程分四段:Reserve(CPU 端确定哪些光源能拿到 Atlas Tile) → BuildList(每个 Tile 一个 RendererList) → Render(写入深度到 Atlas) → Sample(光照 Pass 中按表面位置采样并融合)。前三段在 ShadowsPass 中完成,第四段嵌入到 Lit Shader 的 GetLighting 调用链里。

1.2 双 Atlas 设计

阴影 Atlas 选择两张而不是一张统一图:

Atlas 内容 Tile 布局 典型分辨率
_DirectionalShadowAtlas 方向光 CSM 每光源占 cascade 数 Tile(1-4) 1024 / 2048 / 4096
_OtherShadowAtlas 聚光 + 点光 聚光 1 Tile、点光 6 Tile 1024 / 2048

分离的设计理由:

  • 采样上下文不同:方向光用正交投影(线性深度)+ Cascade 选择逻辑;其他光用透视投影 + Tile 索引查找
  • 数量级不同:方向光最多 4 个但 Tile 大;其他光最多 16 个但 Tile 小
  • 更新频率不同:方向光阴影对相机移动敏感(每帧重建 cascade);点光阴影只在光源或场景变化时重建

合并到单 Atlas 会让 Shader 端的采样逻辑被迫统一处理两种投影类型,反而增加复杂度。

1.3 ShadowTextures 资源声明

ShadowsPass 通过 ShadowTextures 把两个 Atlas 句柄打包传递:

1
2
3
4
5
6
7
8
9
public readonly ref struct ShadowTextures
{
public readonly TextureHandle directionalAtlas, otherAtlas;
public ShadowTextures(TextureHandle directional, TextureHandle other)
{
this.directionalAtlas = directional;
this.otherAtlas = other;
}
}

Render Graph 中的资源创建有一个关键标志:

1
2
3
4
5
6
7
var desc = new TextureDesc(atlasSize, atlasSize)
{
depthBufferBits = DepthBits.Depth32,
isShadowMap = true, // 关键:跳过 stencil 分配
name = "Directional Shadow Atlas"
};
TextureHandle directional = renderGraph.CreateTexture(desc);

isShadowMap = true 让引擎知道这张 RT 只用于阴影深度测试,省略 stencil buffer 的分配。在 1024² Atlas 上,这是 4MB → 5MB 的差异;4096² 上是 64MB → 80MB 的差异——对移动端是显著节省。

如果某个 Atlas 不需要(比如场景没有点光阴影),还是必须提供有效的 TextureHandle,否则 Shader 端的 SAMPLER_CMP 绑定会失败。Render Graph 提供了 defaultResources.defaultShadowTexture 作为 1×1 fallback:

1
2
3
TextureHandle other = otherShadowedLightCount > 0
? renderGraph.CreateTexture(otherDesc)
: renderGraph.defaultResources.defaultShadowTexture;

2. 方向光与级联阴影贴图

2.1 CSM 的设计动机

方向光照射范围是整个场景,单张 Shadow Map 覆盖到 max shadow distance 时面临精度灾难。一张 4096² Atlas、覆盖 100 米半径,每 texel 对应世界空间约 24cm——近处角色脚下的阴影锯齿肉眼可见,远处又过度精细毫无收益。

CSM(Cascaded Shadow Maps)的核心思路:把视锥体按距离切片,每片用一张独立的 Shadow Map。近处切片覆盖小范围(高分辨率精度),远处切片覆盖大范围(低分辨率即可)。每像素根据其距相机的距离选择最合适的 cascade 采样。

数学描述:第 个 cascade 的覆盖半径 通过 ratio sliders 配置,第 个 cascade 的远界等于 max shadow distance 。Catlike 实现支持 1-4 cascade,4 是 Unity 引擎的硬上限。

1
2
3
4
5
6
相机 ──┐
│ Cascade 0 Cascade 1 Cascade 2 Cascade 3
├──────────┬──────────────────┬──────────────────────────┬───────────────────────────►
r0 r1 r2 d_max
│ 高精度 │ 中精度 │ 低精度 │ 最低精度
│ (10% 距离) │ (25% 距离) │ (50% 距离) │ (远界 d_max)

每个 cascade 在 Atlas 中占据一个独立 Tile。4 cascade × 4 光源 → 16 Tile,按 4×4 网格平铺在 Atlas 中。

2.2 Cascade 数据组织

每个 cascade 携带两组数据:CullingSphere(用于 Shader 端选择)和 ShadowMatrix(用于 Atlas 采样坐标转换)。

1
2
3
4
5
6
7
8
9
10
11
[StructLayout(LayoutKind.Sequential)]
public struct DirectionalShadowCascade
{
public Vector4 cullingSphere; // XYZ: 中心, W: 半径²(预平方便于比较)
public Vector4 data; // X: texelSize × normalBiasFactor, YZW: 备用

public static int stride => 4 * 4 * 2; // 32 bytes
}

StructuredBuffer<DirectionalShadowCascade> _DirectionalShadowCascades;
StructuredBuffer<float4x4> _DirectionalShadowMatrices;

CPU 端从 cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives 拿到每 cascade 的视图矩阵、投影矩阵、CullingSphere:

1
2
3
4
5
6
7
8
9
10
11
12
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
visibleLightIndex,
cascadeIndex, // 当前 cascade
cascadeCount, // 总 cascade 数
cascadeRatios, // Vector3,每 cascade 的覆盖比例
tileSize, // Atlas Tile 像素尺寸
light.shadowNearPlane,
out Matrix4x4 viewMatrix,
out Matrix4x4 projectionMatrix,
out ShadowSplitData splitData);

cullingSphere = splitData.cullingSphere;

splitData.cullingSphere 是 Unity 帮我们计算好的最优包围球,球心 + 半径平方刚好覆盖该 cascade 的体积。

2.3 Shader 端 Cascade 选择

每个像素需要在着色时选定一个 cascade。最简单的方法:从近到远遍历,找到第一个包含该像素世界位置的 CullingSphere。

HLSL
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
36
37
38
39
40
41
struct ShadowData
{
int cascadeIndex;
float cascadeBlend;
float strength;
int shadowMaskChannel;
float4 shadowMask;
};

ShadowData GetShadowData(Surface surfaceWS)
{
ShadowData data;
data.shadowMask = 0.0;
data.shadowMaskChannel = -1;
data.strength = FadedShadowStrength(
surfaceWS.depth, _ShadowDistanceFade.x, _ShadowDistanceFade.y);

int i;
for (i = 0; i < _CascadeCount; i++)
{
DirectionalShadowCascade cascade = _DirectionalShadowCascades[i];
float distSqr = DistanceSquared(surfaceWS.position, cascade.cullingSphere.xyz);
if (distSqr < cascade.cullingSphere.w)
{
// 找到最近的覆盖 cascade
float fade = FadedShadowStrength(
distSqr, _CascadeData[i].x, _CascadeData[i].y);
if (i == _CascadeCount - 1)
data.strength *= fade;
else
data.cascadeBlend = fade;
break;
}
}

if (i == _CascadeCount)
data.strength = 0.0; // 所有 cascade 都不覆盖:放弃实时阴影

data.cascadeIndex = i;
return data;
}

注意几个关键设计:

  • CullingSphere 用平方距离比较,省一次 sqrt(每像素都要做的开销)
  • 最远 cascade 的 strength 渐隐到 0:避免 cascade 边界处出现硬切换
  • i == _CascadeCount 表示像素超出所有 cascade:直接放弃实时阴影、依赖 Shadow Distance Fade

2.4 Cascade Blend:边界过渡

Cascade 之间的边界总是会有不连续——相邻 cascade 分辨率不同,Acne、Bias、PCF 核大小都不一样。让两个相邻 cascade 在过渡区共存采样能消除接缝。

Custom SRP 提供两种 blend 模式(toggle 切换):

Soft Blend:每像素同时采样当前 cascade 和下一 cascade,按距离权重 lerp。

1
2
3
4
5
6
7
8
9
float shadow = GetCascadedShadow(directional, global, surfaceWS);
#if defined(_CASCADE_BLEND_SOFT)
if (global.cascadeBlend < 1.0)
{
directional.tileIndex += 1;
float nextShadow = GetCascadedShadow(directional, global, surfaceWS);
shadow = lerp(nextShadow, shadow, global.cascadeBlend);
}
#endif

成本是过渡区像素双倍采样。

Dither Blend:每像素只采样一个 cascade,但根据屏幕 dither pattern 在过渡区随机选择当前或下一 cascade。

1
2
3
4
5
#if defined(_CASCADE_BLEND_DITHER)
float dither = InterleavedGradientNoise(positionCS.xy, 0);
if (global.cascadeBlend < dither)
directional.tileIndex += 1;
#endif

Dither 模式只采样一次,但视觉上有噪点。

实践选择:桌面端默认 Soft(视觉平滑),移动端建议 Dither(性能优先)。Soft 模式在 PCF 5×5/7×7 时单像素采样数翻倍,移动端难以承担。

2.5 阴影写入:ShadowCaster Pass

Atlas 写入阶段每个 Tile 都需要一个独立的 RendererList——这是 Note 1 中”RendererList 不可复用”约束的直接后果。

CSHARP
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 RenderDirectionalShadows()
{
int tileSize = atlasSize / split; // split = sqrt(maxTiles),4 cascade × 1 光源用 split=2

for (int i = 0; i < shadowedDirectionalLightCount; i++)
{
for (int c = 0; c < cascadeCount; c++)
{
int tileIndex = i * cascadeCount + c;
Vector2 offset = SetTileViewport(tileIndex, split, tileSize);

cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
light.visibleLightIndex, c, cascadeCount, cascadeRatios,
tileSize, 0f,
out Matrix4x4 view, out Matrix4x4 proj, out ShadowSplitData splitData);

// 写入 Cascade 数据
directionalShadowCascades[c] = new DirectionalShadowCascade(
splitData.cullingSphere, tileSize, settings.directional.filterSize);

// 计算 Atlas 矩阵
directionalShadowMatrices[tileIndex] = ConvertToAtlasMatrix(
proj * view, offset, split);

// RendererList 创建(Render Graph 阶段)
BuildRendererList(visibleLightIndex, splitData);

// 实际绘制(Execute 阶段)
cmd.SetViewProjectionMatrices(view, proj);
cmd.SetGlobalDepthBias(0f, light.slopeScaleBias);
cmd.DrawRendererList(renderInfo[tileIndex].rendererList);
cmd.SetGlobalDepthBias(0f, 0f);
}
}
}

SetGlobalDepthBias 在 ShadowCaster Pass 中独立设置,绘制完后立即清零——避免污染后续 Pass。

⚠️ Shadow Pancaking 与 light.shadowNearPlane:方向光阴影使用正交投影,引擎默认会把投影体积紧密贴合 cascade 的可见包围盒。这带来一个极其隐蔽的 Bug——位于相机背后的高大遮挡物(比如玩家身后的高楼、悬崖)会被正交投影的 Near Plane 裁掉,导致它们无法投射阴影到相机前方的地面上,明明应该有的影子却消失了。这种现象叫 Shadow Pancaking——可见区域被”压扁”丢失了 Z 方向的可投射深度。修复方式是 light.shadowNearPlane 参数:它把阴影相机的近裁平面向光源方向往后推一段距离,让背后的高物体也能被纳入投影体积。Light 组件的 Near Plane 滑块就是它的暴露入口,默认值偏小,对城市/室外场景应该上调到 1-3 米。这是方向光阴影排查列表中”明明所有 bias 都对、阴影还是缺一块”的首选检查项。

2.6 Atlas 矩阵转换

Shader 端采样需要把世界坐标转换到 Atlas UV 空间。ConvertToAtlasMatrix 把投影矩阵后乘以 Atlas Tile 偏移与缩放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static Matrix4x4 ConvertToAtlasMatrix(Matrix4x4 m, Vector2 offset, int split)
{
// Z 轴反转(部分图形 API 深度方向问题)
if (SystemInfo.usesReversedZBuffer)
{
m.m20 = -m.m20; m.m21 = -m.m21; m.m22 = -m.m22; m.m23 = -m.m23;
}

float scale = 1f / split;
m.m00 = (0.5f * (m.m00 + m.m30) + offset.x * m.m30) * scale;
m.m01 = (0.5f * (m.m01 + m.m31) + offset.x * m.m31) * scale;
m.m02 = (0.5f * (m.m02 + m.m32) + offset.x * m.m32) * scale;
m.m03 = (0.5f * (m.m03 + m.m33) + offset.x * m.m33) * scale;
m.m10 = (0.5f * (m.m10 + m.m30) + offset.y * m.m30) * scale;
m.m11 = (0.5f * (m.m11 + m.m31) + offset.y * m.m31) * scale;
m.m12 = (0.5f * (m.m12 + m.m32) + offset.y * m.m32) * scale;
m.m13 = (0.5f * (m.m13 + m.m33) + offset.y * m.m33) * scale;
m.m20 = 0.5f * (m.m20 + m.m30);
m.m21 = 0.5f * (m.m21 + m.m31);
m.m22 = 0.5f * (m.m22 + m.m32);
m.m23 = 0.5f * (m.m23 + m.m33);
return m;
}

矩阵融合了三步变换:

  • 投影矩阵的 NDC 输出从 [-1, 1] 映射到 [0, 1](标准 UV 空间)
  • 加上 Atlas Tile 偏移
  • 按 split 数缩放到 Tile 子区域

预先计算并存入 StructuredBuffer,Shader 端只需一次矩阵乘法即可拿到 Atlas UV:

1
2
float4 positionShadowAtlas = mul(_DirectionalShadowMatrices[tileIndex],
float4(positionWS, 1.0));

3. PCF 滤波与质量等级

3.1 硬件 PCF 与软阴影需求

Shadow Map 采样的本质问题:阴影投射图与接收点的几何精度不匹配,单点采样会产生严重锯齿。PCF(Percentage-Closer Filtering) 通过对深度比较结果进行加权平均产生软边缘。

现代 GPU 提供硬件 PCF:使用 SAMPLER_CMP 类型的 sampler 配合 SampleCmp 系列指令,单次调用就能完成 2×2 双线性插值的 PCF。Catlike 实现基于此构建更大核:

1
2
TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
SAMPLER_CMP(SHADOW_SAMPLER); // 使用 ShaderLibrary 的对比采样器宏

SAMPLER_CMP 在创建时就配置为 LinearClamp + LessEqual 比较函数。

3.2 Filter Quality 三档统一

Catlike 早期版本支持 4 个独立的 PCF 模式(2×2 / 3×3 / 5×5 / 7×7),方向光与其他光各自配置——这意味着 4×4 = 16 种 keyword 排列。3.2.0 简化版本统一为三档全局质量,方向光与其他光使用相同等级:

Filter Quality Kernel Tap 数 Keyword
Low 3×3 4 (default)
Medium 5×5 9 _SHADOW_FILTER_MEDIUM
High 7×7 16 _SHADOW_FILTER_HIGH

💡 2×2 模式被取消的工程含义:硬件 PCF 默认就是 2×2 双线性,单独编译”原生 2×2”作为最低级是冗余的。3×3 已经覆盖最低软阴影需求,且只比 2×2 多 3 个 tap。简化决策让 keyword 排列从 16 种降到 3 种,Shader 编译时间下降 80%+、生成包体减小 75%。这是 multi_compile 收敛的经典案例——后续 Note 8 会在工程架构层展开关键字管理策略。

Tap 数不等于 kernel 边长平方:3×3 kernel 实际只用 4 个 tap(每 tap 是一次硬件 2×2 PCF,正好覆盖 2×2 区域,4 个 tap 重叠后等价 3×3 范围)。这个数学魔法来自 RP Core Library 的 tent filter 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
real SampleShadow_PCF_Tent_3x3(float4 shadowMapTexture_TexelSize,
float3 coord, /* sampler params */)
{
real fetchesWeights[4];
real2 fetchesUV[4];
SampleShadow_ComputeSamples_Tent_3x3(
shadowMapTexture_TexelSize, coord.xy,
fetchesWeights, fetchesUV);

return fetchesWeights[0] * SAMPLE_TEXTURE2D_SHADOW(/*...*/, fetchesUV[0])
+ fetchesWeights[1] * SAMPLE_TEXTURE2D_SHADOW(/*...*/, fetchesUV[1])
+ fetchesWeights[2] * SAMPLE_TEXTURE2D_SHADOW(/*...*/, fetchesUV[2])
+ fetchesWeights[3] * SAMPLE_TEXTURE2D_SHADOW(/*...*/, fetchesUV[3]);
}

5×5 用 9 个 tap 覆盖 6×6 范围、7×7 用 16 个 tap 覆盖 8×8 范围——边界由 tent 权重平滑过渡,比直接 N² 个独立 tap 高效得多。

3.3 滤波尺寸传递

Filter kernel 越大,需要的 normal bias 也越大(防止大核 PCF 漏出几何自身)。CPU 端按质量等级计算 filter size,写入 cascade data:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static int GetFilterSize(FilterQuality quality)
{
return quality switch
{
FilterQuality.Low => 3,
FilterQuality.Medium => 5,
FilterQuality.High => 7,
_ => 3
};
}

// CPU 端传递
cascadeData.x = texelSize * (1.4142136f * filterSize); // 1.414 = sqrt(2)

sqrt(2) × filterSize × texelSize 是 PCF 核对角线在世界空间的物理尺寸,这是 normal bias 应该至少抵消的位移量。


4. 阴影伪影修复

阴影计算的浮点离散化必然产生伪影。三种常见伪影对应三种修复手段,需要严格区分。

4.1 Shadow Acne:自阴影伪影

现象:表面在自身阴影下出现条纹斑驳,特别是在表面法线接近垂直于光线方向时。

成因:Shadow Map 存储的是离散深度采样,相邻 texel 之间是阶梯状跳变。表面在两个 texel 之间的位置时,深度比较可能错判自身被自身遮挡。

修复:Depth Bias,在写入 Shadow Map 时把投射体推向远离光源的方向:

1
cmd.SetGlobalDepthBias(constantBias: 0f, slopeScaleBias: light.slopeScaleBias);

Slope Scale Bias 是关键——固定 bias 在表面法线接近垂直光线时不够(acne 严重),垂直时又过度(造成 Peter-Panning)。Slope Scale Bias 的标准表达式:

底层逻辑非常优雅——通过深度在屏幕空间的偏导数,本质上反映了 Shadow Map 当前像素与相邻像素的深度差),完美实现了”表面越倾斜(偏导数越大),推离的深度偏移就越多”的自适应效果。GPU 硬件在光栅化阶段会自动计算这两个偏导数(同一 quad 内的相邻像素深度差),驱动直接消费这些偏导数生成最终 bias。开发者只需要给定 Constant 与 Slope 两个标量。

Catlike 实现把 Slope Scale Bias 暴露为光源属性 light.shadowBias,每个光源独立配置。典型值 0.5-2.0。

4.2 Peter-Panning:阴影漂浮

现象:阴影从投射体的脚下”漂离”,看起来像物体悬空。

成因:Depth Bias 过大,把投射体在 Shadow Map 中推得太远,离地面接触点被错误地判定为不在阴影中。

修复:减小 Depth Bias。但减小到不漂浮时往往又出现 Acne——这是经典的 trade-off。

更好的解决方法是 Normal Bias:不在 Shadow Map 写入时调整,而是在采样时把表面位置沿法线推向光源。这等价于”假装表面比实际位置离光近一点点”,避免了 Acne 又不会让阴影漂浮。

1
2
3
float3 normalBias = surfaceWS.interpolatedNormal * (info.normalBias * cascadeData.x);
float3 positionShadow = surfaceWS.position + normalBias;
float4 positionShadowAtlas = mul(matrix, float4(positionShadow, 1.0));

cascadeData.x = texelSize × sqrt(2) × filterSize 已经计算好,每 cascade 不同——大 cascade 用更大 normalBias 抵消其更粗的 texel。

4.3 Light Bleeding:光泄漏

现象:阴影投射体被薄表面挡住时,阴影”穿透”出来,特别在 PCF 大核时。

成因:PCF 在投射体边缘做权重混合,如果接收面贴近投射体边缘,平均深度可能弱于实际阴影深度。

修复:本质上无法完美解决,缓解手段包括:

  • 使用 VSM/EVSM 等高级阴影技术(Catlike 实现未涉及)
  • 限制 PCF 核大小(High quality 的 7×7 在薄物体场景慎用)
  • 美术层面避免极端薄结构与近距离阴影投射

4.4 三种 Bias 的工程总结

Bias 类型 应用阶段 调控参数 主治伪影 副作用
Constant Depth Bias 写入 全局 Shadow Acne Peter-Panning
Slope Scale Bias 写入 每光源 Acne 倾角问题 极倾斜面仍可能 Peter-Panning
Normal Bias 采样 每 cascade Acne 不引起 Peter-Panning 阴影边缘略微外扩

工程实践:Slope Scale Bias 0.5-1.5 + Normal Bias 1.0-2.0 是大多数场景的安全初值,Constant Depth Bias 设为 0 让 Slope Scale 自适应工作。


5. 聚光灯与点光源阴影

5.1 聚光灯:单 Tile 透视投影

聚光灯阴影最简单——锥形覆盖范围天然适合单张透视 Shadow Map。Atlas 中每个聚光占一个 Tile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void RenderSpotShadows(int index, int split, int tileSize)
{
ShadowedOtherLight light = shadowedOtherLights[index];
var settings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex);

cullingResults.ComputeSpotShadowMatricesAndCullingPrimitives(
light.visibleLightIndex,
out Matrix4x4 view, out Matrix4x4 proj, out ShadowSplitData splitData);

int tileIndex = light.atlasOffset; // 在 OtherShadowAtlas 中的位置
Vector2 offset = SetTileViewport(tileIndex, split, tileSize);

// tile data:Atlas 范围 + bias
SetOtherTileData(tileIndex, offset, 1f / split, normalBias);

otherShadowMatrices[tileIndex] = ConvertToAtlasMatrix(
proj * view, offset, split);

cmd.SetViewProjectionMatrices(view, proj);
cmd.SetGlobalDepthBias(0f, light.slopeScaleBias);
cmd.DrawRendererList(rendererList);
cmd.SetGlobalDepthBias(0f, 0f);
}

聚光灯阴影的特殊点:透视投影下,texel 的世界空间尺寸随距离变化。这意味着 normal bias 不能用单一标量——离光近的位置 texel 小、bias 应该小;离光远的位置 texel 大、bias 应该大。

CPU 端预计算”距离 1 处的 normal bias”传给 Shader,Shader 端按实际深度缩放:

1
2
3
4
5
// CPU 端:聚光灯距离 1 处的世界空间 tile 尺寸
float texelSize = 2f * Mathf.Tan(light.spotAngle * 0.5f * Mathf.Deg2Rad) / tileSize;
float filterSize = texelSize * filterSizeMultiplier;
float bias = light.normalBias * filterSize * 1.4142136f;
SetOtherTileData(tileIndex, offset, 1f / split, bias);
1
2
3
// Shader 端
float distanceToLight = dot(surfaceWS.position - lightPos, lightDirection);
float scaledNormalBias = otherShadowData.normalBias * distanceToLight;

5.2 点光源:Cubemap 与 Atlas 6 面布局

点光源向所有方向辐射,阴影需要覆盖 6 个面——本质上是一个 cubemap。但为了与 Atlas 架构兼容,Custom SRP 把 cubemap 6 个面平铺为 Atlas 中的 6 个独立 Tile。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const int maxTilesPerLight = 6;

void ReserveOtherShadows(Light light, int visibleLightIndex)
{
bool isPoint = light.type == LightType.Point;
int newLightCount = shadowedOtherLightCount + (isPoint ? 6 : 1);

if (newLightCount > maxShadowedOtherLightCount)
return; // Atlas 不够,本光源放弃实时阴影

shadowedOtherLights[shadowedOtherLightCount] = new ShadowedOtherLight {
visibleLightIndex = visibleLightIndex,
slopeScaleBias = light.shadowBias,
normalBias = light.shadowNormalBias,
isPoint = isPoint
};

shadowedOtherLightCount = newLightCount;
}

绘制时遍历 6 面:

CSHARP
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
void RenderPointShadows(int index, int split, int tileSize)
{
ShadowedOtherLight light = shadowedOtherLights[index];

// 点光的 fov 永远是 90° → distance 1 处 tile 尺寸永远是 2
float texelSize = 2f / tileSize;
float filterSize = texelSize * filterSizeMultiplier;
float bias = light.normalBias * filterSize * 1.4142136f;
float tileScale = 1f / split;

for (int face = 0; face < 6; face++)
{
cullingResults.ComputePointShadowMatricesAndCullingPrimitives(
light.visibleLightIndex,
(CubemapFace)face,
fovBias: 0f,
out Matrix4x4 view, out Matrix4x4 proj, out ShadowSplitData splitData);

view.m11 = -view.m11; view.m12 = -view.m12; view.m13 = -view.m13;
// 翻转 Y 轴:Cubemap 在 Unity 中 Y 与世界相反

int tileIndex = light.atlasOffset + face;
Vector2 offset = SetTileViewport(tileIndex, split, tileSize);
SetOtherTileData(tileIndex, offset, tileScale, bias);
otherShadowMatrices[tileIndex] = ConvertToAtlasMatrix(proj * view, offset, split);

cmd.SetViewProjectionMatrices(view, proj);
cmd.SetGlobalDepthBias(0f, light.slopeScaleBias);
cmd.DrawRendererList(rendererListPerFace[face]);
}
cmd.SetGlobalDepthBias(0f, 0f);
}

6 个面对应 6 个独立的 RendererList——这是 Note 1 中”RendererList 不可复用”的最直接体现。即使 6 个 List 的 ShadowDrawingSettings 几乎相同,必须分别创建。

5.3 Shader 端面选择

点光源采样时需要根据表面到光源的方向选择正确的面。Cubemap 标准的”abs 最大轴”算法:

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
static const float3 pointShadowPlanes[6] = {
float3( -1.0, 0.0, 0.0), // +X face uses -X plane
float3( 1.0, 0.0, 0.0), // -X face uses +X plane
float3( 0.0, -1.0, 0.0),
float3( 0.0, 1.0, 0.0),
float3( 0.0, 0.0, -1.0),
float3( 0.0, 0.0, 1.0)
};

float GetPointShadow(OtherShadowData other, ShadowData global, Surface surfaceWS,
float3 lightDirectionWS)
{
// 选择面:abs 最大的轴
float3 lightVecAbs = abs(lightDirectionWS);
float maxAbs = max(lightVecAbs.x, max(lightVecAbs.y, lightVecAbs.z));
int faceOffset = 0;

if (maxAbs == lightVecAbs.x) faceOffset = lightDirectionWS.x > 0 ? 0 : 1;
else if (maxAbs == lightVecAbs.y) faceOffset = lightDirectionWS.y > 0 ? 2 : 3;
else faceOffset = lightDirectionWS.z > 0 ? 4 : 5;

int tileIndex = other.tileIndex + faceOffset;
float3 normalBias = surfaceWS.interpolatedNormal *
pointShadowPlanes[faceOffset] * other.normalBias;

return SampleOtherShadow(tileIndex, other, surfaceWS.position + normalBias);
}

Normal bias 的方向用面法线(pointShadowPlanes[face])而不是表面法线——因为我们要把表面”推离”该 cubemap 面。

5.4 Atlas 容量与 fallback

maxShadowedOtherLightCount = 16 意味着 Atlas 最多容纳 16 个 Tile。但点光每个吃 6 Tile,所以实际能容纳的”光源数”动态变化:纯聚光最多 16 个,纯点光最多 2 个,混合则按 1 + 6 + 1 + 6 + … 累加。

容量不足时,多出来的光源不分配 Tile,回退到只用 baked shadow(如果有)或不投阴影。ReserveOtherShadows 中处理 fallback:

1
2
3
4
5
6
if (newLightCount > maxShadowedOtherLightCount ||
!cullingResults.GetShadowCasterBounds(visibleLightIndex, out _))
{
// Atlas 不够 或 没有阴影投射体:给一个"只用 shadow mask"的标记
return new Vector4(-light.shadowStrength, 0f, 0f, maskChannel);
}

shadowStrength 取负值是 fallback 信号,Shader 端 if (strength <= 0) 会绕过实时采样直接用 shadowmask。这种”在数据本身编码状态”的 trick 比额外开 keyword 便宜得多。


6. Shadow Mask:烘焙与实时混合

6.1 设计动机

完全实时阴影的代价:每帧重渲染所有投射体到 Atlas、所有像素采样 Shadow Map。对静态对象这是浪费——它们的阴影本来就不变。

完全烘焙阴影的代价:动态对象(角色、可移动物体)无法接收阴影;光源不能动。

Mixed Lighting 是中间方案:静态对象的阴影烘焙,动态对象的阴影实时计算,两者在 Shader 端融合。Shadowmask 是 Mixed Lighting 的核心数据载体。

6.2 Shadowmask 数据结构

Shadowmask 是一张和 Lightmap 共享 UV 布局的额外贴图,每 texel 4 通道(RGBA),每通道存储一个光源对该 texel 的烘焙遮挡值(0 = 全黑、1 = 全亮)。

每光源被烘焙器分配一个 channel,最多 4 个光源可以同时占用同一区域。超过 4 重叠的光源被强制 fallback 到完全 baked 模式(不能再实时切换)。

烘焙器的分配策略:尽可能让不重叠的光源共享 channel。比如室内场景里几十个非重叠的吊灯可能全部使用 R 通道。

6.3 两种 Shadowmask 模式

Distance Shadowmask(推荐默认):

  • 在 shadow distance 之内,全部使用实时阴影(包括静态对象)
  • 在 shadow distance 之外,静态对象使用 shadowmask、动态对象使用 occlusion probe

Shadowmask(性能优化):

  • 静态对象始终使用 shadowmask(不投射实时阴影)
  • 动态对象使用实时阴影
  • 静态对象自身永远没有实时阴影投射

两者的 trade-off:

模式 静态对象阴影 Shadow Atlas 工作量 适用场景
Distance Shadowmask 近端实时(高质量) 大(静态对象也要画) 主流游戏
Shadowmask 始终烘焙(廉价) 小(只画动态) 性能极限优化

6.4 Shader 端融合

PerObjectData.ShadowMask 让引擎把 unity_ShadowMask 自动绑定到当前对象的 Shadowmask 贴图。PerObjectData.OcclusionProbe 把动态对象的 unity_ProbesOcclusion 填充为光照探针烘焙的遮挡值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
float MixBakedAndRealtimeShadows(
ShadowData global, float shadow, int shadowMaskChannel, float strength)
{
float baked = GetBakedShadow(global.shadowMask, shadowMaskChannel);

if (global.shadowMask.always)
{
// Shadowmask 模式:实时 × 静态烘焙取最小
shadow = lerp(1.0, shadow, strength);
shadow = min(baked, shadow);
return shadow;
}

if (global.shadowMask.distance)
{
// Distance Shadowmask 模式:在 cascade 边界淡入烘焙
shadow = lerp(baked, shadow, strength);
return shadow;
}

return lerp(1.0, shadow, strength); // 无 shadowmask
}

min(baked, shadow) 的语义:两者都说”被遮挡”才是被遮挡——这是正确的物理融合(最暗值优先)。

6.5 GPU Instancing 兼容性

unity_ProbesOcclusion(动态对象的 occlusion 数据)在 GPU Instancing 路径下需要被打包到 instance buffer。Unity 的 UnityInstancing 库只在 SHADOWS_SHADOWMASK 宏定义时才处理这个字段:

1
2
3
4
5
6
// 在 include UnityInstancing 之前必须定义
#if defined(_SHADOW_MASK_DISTANCE) || defined(_SHADOW_MASK_ALWAYS)
#define SHADOWS_SHADOWMASK
#endif

#include "UnityInstancing.hlsl"

漏掉这个宏定义会让 Shadowmask 模式下的实例化对象出现错乱(每实例读到错误的 occlusion 值)。这是个非常隐蔽的 bug——只有在”Shadowmask + Instancing + 动态对象”三者同时出现时才暴露。


7. Unity 6 阴影剔除并行化

7.1 老路径:cull-draw 串行

Unity 6 之前,每个阴影 Tile 的渲染是串行的:

1
2
3
4
5
6
Light0 cascade0 cull → Light0 cascade0 draw
→ Light0 cascade1 cull → Light0 cascade1 draw
→ Light0 cascade2 cull → ...
→ Light1 cull → Light1 draw
→ Point0 face0 cull → Point0 face0 draw
→ ...

每个 cull 操作是单线程的 CPU 工作。多光源场景下这是个明显的串行瓶颈——10 个聚光 + 2 个点光就有 10 + 2×6 = 22 次串行 cull。

7.2 新路径:cull 并行化

Unity 6 引入 LightShadowCasterCullingInfo API,支持把所有 cull 工作打包提交、底层并行调度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[StructLayout(LayoutKind.Sequential)]
struct LightShadowCasterCullingInfo
{
public int visibleLightIndex;
public RangeInt splitRange; // 该光源的 cascade/face 在 splitData 数组中的范围
public BatchCullingProjectionType projectionType;
}

NativeArray<LightShadowCasterCullingInfo> cullingInfos = new(...);
NativeArray<ShadowSplitData> splitData = new(...);

// CPU 端填充 cullingInfos 与 splitData

cullingResults.ScheduleLightShadowCasterCulling(cullingInfos, splitData)
.Complete();

引擎内部把 cull 工作提交到 Job System,多核 CPU 并行处理。绘制阶段才依次发出 DrawRendererList。

新流程的时序:

1
2
3
[All culls in parallel: Light0c0, Light0c1, Light0c2, Light1, Point0f0, ...]

[All draws sequential: Light0c0, Light0c1, ..., Point0f0, ...]

Cull 阶段在 ScheduleLightShadowCasterCulling().Complete() 调用处合流,之后绘制按顺序执行(受限于 Atlas Tile 写入的顺序依赖)。

7.3 工程收益

测试场景 8 方向光 cascade × 4 + 16 聚光的极端配置:

  • 老串行路径 cull 总耗时约 4-6ms(CPU 单核)
  • 新并行路径 cull 总耗时约 1-2ms(4-8 核并行)

对于阴影密集的场景这是 60→90 FPS 的差距。Unity 6 项目应该默认启用此路径,无需开发者额外配置——只要把 ShadowsPass 重构为”先 BuildList 阶段集中提交 cull、再 Render 阶段集中绘制”即可享受收益。


8. TA Takeaway

8.1 阴影系统的总成本图谱

阴影是渲染中最容易吃掉性能预算的子系统之一。完整成本图谱包含:

1
2
3
4
5
6
7
8
9
10
11
12
13
CPU 端(每帧):
├─ ReserveShadows: 遍历光源、分配 Atlas Tile (< 0.1ms)
├─ ComputeMatrices: cascade/spot/point 矩阵计算 (< 0.5ms)
└─ ShadowCasterCulling: 多 List 并行剔除 (1-3ms)

GPU 端(每帧):
├─ Atlas 写入: 每 Tile 一次 ShadowCaster Pass (5-20ms 主要瓶颈)
│ └─ 投射体顶点数 × Tile 数 × 阴影投射 Shader
└─ Shader 端采样: PCF Tap × 像素数 (1-5ms)
├─ Cascade 选择
├─ Cascade Blend (Soft 模式翻倍采样)
├─ PCF kernel (3×3=4tap / 5×5=9tap / 7×7=16tap)
└─ Shadowmask 融合

最大变量是 Atlas 写入阶段——它取决于场景中投射体的复杂度与数量。一个有 1 万个投射体的森林场景,仅阴影写入就可能吃掉 30ms+。这是为什么 Shadowmask 模式(不画静态投射体)在性能极限场景中是必备技术。

8.2 配置优先级

调试阴影性能时按以下顺序检查:

  1. Atlas 尺寸:默认 1024 在桌面端常常不够,2048 是甜区,4096 仅用于高端 PC
  2. Cascade 数量:4 是最高质量但最贵;2-3 cascade 配合稍紧的 max distance 通常视觉无差
  3. Shadow Distance:远端 fade 区设短一点(典型 0.1 of distance),节省远 cascade 工作量
  4. Filter Quality:Medium 是性价比甜区;High 仅在镜头特写时启用
  5. Cascade Blend:移动端用 Dither,桌面端用 Soft
  6. Shadowmask 模式:静态光源密集的室内/城市场景必启 Distance Shadowmask
  7. Other Lights 数量:每个点光吃 6 Tile,控制总数;非重要点光关闭实时阴影
  8. 深度缓冲精度(移动端关键):默认 DepthBits.Depth32 在桌面端无忧,但在移动端 TBR 架构下,分配 32-bit 深度图意味着片上内存占用与回写带宽双重翻倍——对一张 2048² Atlas 是 16MB → 8MB 的真实差异。移动端项目通常将 Shadow Atlas 的深度精度从 32-bit 降至 DepthBits.Depth16直接省下一半的显存带宽与片上内存占用。代价是 16-bit 深度的离散步长变大(在远 cascade 处尤其明显),需要更精细地调节 Slope Scale Bias 与 Normal Bias 抵消精度下降带来的 Acne。这种”用调参换带宽”的取舍在移动端是默认操作;DepthBits.Depth24 是介于两者之间的折中选择,在某些 Mali GPU 上反而比 Depth16 更稳定(驱动实现差异)。

8.3 Atlas 容量的 Tile 预算思维

把 Atlas 视为”Tile 预算”管理:

  • DirectionalAtlas Tile 数 = cascadeCount × maxDirectionalLights,典型 16
  • OtherAtlas Tile 数 = maxShadowedOtherLightCount,典型 16

每多一个点光阴影 = 减少 6 个其他光源阴影预算。这意味着场景设计时:

  • 主光(关键光源)独占足够 cascade
  • 点光阴影是稀缺资源——只给关键点光(角色火把、英雄技能特效)
  • 大量装饰性光源用 baked shadow + shadowmask 不占 Atlas

8.4 Shader 关键字爆炸的教训

阴影系统是 Shader 关键字最容易爆炸的子系统:方向光 PCF(4 模式)× 其他光 PCF(4 模式)× Cascade Blend(3 模式)× Shadowmask(3 模式)= 144 种排列

3.2.0 简化后变成 PCF(3 模式)× Blend(2 模式)× Shadowmask(3 模式)= 18 种排列

每个排列都是独立编译的 Shader Variant,影响:

  • 编译时间(首次 build 与 Shader 修改后重编)
  • Player 包体(所有 Variant 都打包)
  • 运行时 Shader 加载(首次切换 Variant 卡顿)

关键字数量与项目质量正相关、与构建效率负相关——找到收敛点是 TA 的核心工作。Note 8 会展开通用的关键字管理策略。

8.5 实践原则

  • 永远启用 Slope Scale Bias:Constant Bias 留 0,Slope Scale 起步 1.0
  • Normal Bias 配合 Filter Quality 调校:Filter 越大、Normal Bias 越大
  • Shadow Distance 是性能/质量天平:每减半距离,cascade 精度翻倍但远端无阴影
  • Distance Shadowmask 几乎总是好选择:除非项目极度性能敏感
  • 点光阴影是奢侈品:默认关闭,只在叙事关键光源开启
  • Render Graph Viewer 看 Atlas 利用率:调试 Tile 浪费、未对齐
  • 不要混用旧/新阴影 API:Unity 6 项目全面迁移到 ShadowCasterCullingInfo + Renderer Lists 路径

关键 API 速查

CSHARP
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
// CPU 端:阴影数据结构
[StructLayout(LayoutKind.Sequential)]
public struct DirectionalShadowCascade { Vector4 cullingSphere, data; }

[StructLayout(LayoutKind.Sequential)]
public struct OtherShadowData { Vector4 tileData; }

// CullingResults 阴影矩阵接口
cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
visibleLightIndex, cascadeIndex, cascadeCount, ratios, tileSize, nearPlane,
out viewMatrix, out projectionMatrix, out splitData);

cullingResults.ComputeSpotShadowMatricesAndCullingPrimitives(
visibleLightIndex, out viewMatrix, out projectionMatrix, out splitData);

cullingResults.ComputePointShadowMatricesAndCullingPrimitives(
visibleLightIndex, cubemapFace, fovBias,
out viewMatrix, out projectionMatrix, out splitData);

// Unity 6 并行剔除
cullingResults.ScheduleLightShadowCasterCulling(cullingInfos, splitData).Complete();

// Render Graph 阴影 RT 创建
new TextureDesc(atlasSize, atlasSize)
{
depthBufferBits = DepthBits.Depth32,
isShadowMap = true, // 跳过 stencil 分配
name = "Shadow Atlas"
};

// fallback shadow texture
renderGraph.defaultResources.defaultShadowTexture;

// 全局 Bias
cmd.SetGlobalDepthBias(constantBias: 0f, slopeScaleBias: 1.0f);
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
// Shader 端:纹理与采样器声明
TEXTURE2D_SHADOW(_DirectionalShadowAtlas);
TEXTURE2D_SHADOW(_OtherShadowAtlas);
SAMPLER_CMP(SHADOW_SAMPLER);

StructuredBuffer<DirectionalShadowCascade> _DirectionalShadowCascades;
StructuredBuffer<float4x4> _DirectionalShadowMatrices;
StructuredBuffer<OtherShadowData> _OtherShadowData;
StructuredBuffer<float4x4> _OtherShadowMatrices;

// PCF 采样宏(RP Core Library)
SampleShadow_PCF_Tent_3x3(...);
SampleShadow_PCF_Tent_5x5(...);
SampleShadow_PCF_Tent_7x7(...);

// 关键字
#pragma multi_compile _ _SHADOW_FILTER_MEDIUM _SHADOW_FILTER_HIGH
#pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER
#pragma multi_compile _ _SHADOW_MASK_ALWAYS _SHADOW_MASK_DISTANCE

// Shadowmask 关键字必须在 UnityInstancing 之前定义
#if defined(_SHADOW_MASK_DISTANCE) || defined(_SHADOW_MASK_ALWAYS)
#define SHADOWS_SHADOWMASK
#endif
#include "UnityInstancing.hlsl"