系列第 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 , 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; public Vector4 data; public static int stride => 4 * 4 * 2 ; } 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, cascadeCount, cascadeRatios, tileSize, 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; 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); directionalShadowCascades[c] = new DirectionalShadowCascade( splitData.cullingSphere, tileSize, settings.directional.filterSize); directionalShadowMatrices[tileIndex] = ConvertToAtlasMatrix( proj * view, offset, split); BuildRendererList(visibleLightIndex, splitData); 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 ) { 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 }; } cascadeData.x = texelSize * (1.4142136f * filterSize);
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; Vector2 offset = SetTileViewport(tileIndex, split, tileSize); 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 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 ; 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]; 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; 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 _)) { 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; public BatchCullingProjectionType projectionType; } NativeArray<LightShadowCasterCullingInfo> cullingInfos = new (...); NativeArray<ShadowSplitData> splitData = new (...); 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 配置优先级 调试阴影性能时按以下顺序检查:
Atlas 尺寸 :默认 1024 在桌面端常常不够,2048 是甜区,4096 仅用于高端 PC
Cascade 数量 :4 是最高质量但最贵;2-3 cascade 配合稍紧的 max distance 通常视觉无差
Shadow Distance :远端 fade 区设短一点(典型 0.1 of distance),节省远 cascade 工作量
Filter Quality :Medium 是性价比甜区;High 仅在镜头特写时启用
Cascade Blend :移动端用 Dither,桌面端用 Soft
Shadowmask 模式 :静态光源密集的室内/城市场景必启 Distance Shadowmask
Other Lights 数量 :每个点光吃 6 Tile,控制总数;非重要点光关闭实时阴影
深度缓冲精度(移动端关键) :默认 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 [StructLayout(LayoutKind.Sequential) ]public struct DirectionalShadowCascade { Vector4 cullingSphere, data; } [StructLayout(LayoutKind.Sequential) ]public struct OtherShadowData { Vector4 tileData; } 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); cullingResults.ScheduleLightShadowCasterCulling(cullingInfos, splitData).Complete();new TextureDesc(atlasSize, atlasSize) { depthBufferBits = DepthBits.Depth32, isShadowMap = true , name = "Shadow Atlas" }; renderGraph.defaultResources.defaultShadowTexture; 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"