Banner

「Custom SRP」:相机系统与工程架构

系列收束篇。前七篇笔记从管线骨架一路走到后处理输出——这些都是单相机内部的故事。本篇拉远视角,关注更宏观的工程层:多相机如何编排、相机级配置如何注入、Shader 关键字如何收敛、调试工具如何串联。这些不是某个具体的渲染技术,但它们决定一个 Custom SRP 项目能否长期维护、能否多人协作、能否在生产环境中稳定运行

TL;DR

  • 多相机栈:Base Camera 渲染主场景到中间 RT、Overlay Camera 在同一 RT 上叠加(UI、武器、第一人称视角)。Render Graph 的资源声明让跨相机 RT 复用变得自然。
  • Per-Camera 配置注入:通过 CustomRenderPipelineCamera 组件在每个 Camera GameObject 上挂载独立设置——RenderingLayerMask、Final Blend、PostFX 开关、Render Scale 都能 per-camera 覆盖。
  • Shader 关键字收敛是工程命脉:原始 144 排列经过 3.2.0 简化合并后压到 18 排列、Shader 编译时间下降 87.5%。shader_featuremulti_compile 的精确划分决定 Player 包体大小。
  • RP Settings 架构演进:从 Asset 内联字段 → 独立 ScriptableObject 集合,分离关注点让大型项目的 RP 配置可以多人协作、按系统独立 review。
  • 三套调试工具串联:Frame Debugger 看命令流、Render Graph Viewer 看资源依赖、RenderDoc 看硬件层细节。每套工具都对应一类问题域,知道何时用哪个是高效调试的前提。

1. 多相机渲染

1.1 渲染栈的设计动机

游戏中常见的相机叠加场景:

  • 第一人称游戏:主相机渲染场景、武器相机渲染近距离武器(避免武器穿墙)
  • UI 系统:主相机 + UI 相机分离 PostFX——UI 不希望被 Bloom 弄糊
  • 小地图 / 后视镜:从其他角度渲染场景到 RT、最终覆盖到主屏角落
  • 过场动画:剧情时切换到电影相机、保持游戏 UI 渲染

这些场景都需要 多个 Camera 协同工作。Custom SRP 通过两种相机角色实现:

角色 行为 用途
Base Camera 渲染完整场景(含天空盒、PostFX) 主视角、主场景
Overlay Camera 在已有 RT 上叠加渲染 UI、武器、特效层

1.2 相机渲染顺序

RenderPipeline.Render(context, cameras) 接收的相机列表已经被引擎按 Camera.depth 排序——depth 小的先渲染,大的后渲染。这意味着:

1
2
3
主相机 (depth = 0) → 渲染到中间 RT
武器相机 (depth = 1) → 在同一 RT 上叠加
UI 相机 (depth = 2) → 最后叠加 UI

相机间的 RT 复用通过 CameraSettings.copyColorCameraSettings.copyDepth 显式声明——下一个相机是否需要前一个相机的 color/depth 作为输入。

1.3 Camera Settings 字段总览

CameraSettings 是 per-camera 的配置容器:

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
[Serializable]
public class CameraSettings
{
public bool copyColor = true;
public bool copyDepth = true;

public RenderingLayerMask renderingLayerMask = ~0u;
public bool maskLights = false;

public enum RenderScaleMode { Inherit, Multiply, Override }
public RenderScaleMode renderScaleMode = RenderScaleMode.Inherit;
public float renderScale = 1f;

public bool overridePostFX = false;
public PostFXSettings postFXSettings = default;

public bool allowFXAA = true;
public bool keepAlpha = false;

[Serializable]
public struct FinalBlendMode {
public BlendMode source;
public BlendMode destination;
}
public FinalBlendMode finalBlendMode = new FinalBlendMode {
source = BlendMode.One,
destination = BlendMode.Zero
};
}

每个字段都对应一类 per-camera 决策:

  • copyColor / copyDepth:是否生成可被后续相机或 Soft Particles 采样的副本
  • renderingLayerMask:相机只渲染哪些 Rendering Layer(Note 4 §5)
  • maskLights:是否对光源也应用 layer mask(用于 per-camera 独立打光)
  • renderScale / renderScaleMode:相机分辨率倍率,三种模式让相机可以继承、相乘或独占覆盖全局设置
  • overridePostFX:是否使用相机专属的 PostFX 配置(小地图相机不需要 Bloom)
  • finalBlendMode:Overlay 相机叠加到中间 RT 时的混合方程

1.4 CustomRenderPipelineCamera 组件

让 CameraSettings 落到具体相机上的方式是挂载 CustomRenderPipelineCamera 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[DisallowMultipleComponent, RequireComponent(typeof(Camera))]
public class CustomRenderPipelineCamera : MonoBehaviour
{
[SerializeField] CameraSettings settings = default;

ProfilingSampler sampler;
public ProfilingSampler Sampler =>
sampler ??= new ProfilingSampler(GetComponent<Camera>().name);

public CameraSettings Settings => settings ??= new CameraSettings();

#if UNITY_EDITOR || DEVELOPMENT_BUILD
void OnEnable()
{
sampler = null; // 相机改名后清除缓存
}
#endif
}

CameraRenderer 读取这个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
ProfilingSampler cameraSampler;
CameraSettings cameraSettings;

if (camera.TryGetComponent(out CustomRenderPipelineCamera crpCamera))
{
cameraSampler = crpCamera.Sampler;
cameraSettings = crpCamera.Settings;
}
else
{
cameraSampler = GetDefaultProfileSampler(camera);
cameraSettings = settings.defaultCameraSettings;
}

没挂组件的相机使用 RP Asset 中的全局默认设置——这种 fallback 设计让 Scene View / Reflection Probe 等隐式相机也能工作。

1.5 Final Blend Mode 与叠加渲染

Overlay 相机的核心机制:最终输出阶段使用自定义 Blend 模式,让本相机渲染结果与 RT 中已有内容混合。

1
2
3
4
public struct FinalBlendMode {
public BlendMode source; // 默认 One
public BlendMode destination; // 默认 Zero
}

不同 BlendMode 组合的常见用途:

Source Destination 效果 用途
One Zero 完全覆盖 Base 相机、不透明覆盖
SrcAlpha OneMinusSrcAlpha 标准 alpha 混合 半透明 UI、武器 overlay
One One 加性混合 发光特效叠加
One OneMinusSrcAlpha Premultiplied Alpha 叠加 已 PMA 处理的 UI 层

PostFXPass 与 FinalPass 的最终 Blit 都使用这个 BlendMode——这是 Overlay 相机能与已有内容融合的根本机制。

⚠️ Overlay Camera 的 Depth Clear 陷阱:Final Blend Mode 处理的是颜色叠加,但深度缓冲(Depth Buffer)也需要单独处理——否则会出现典型的”3D 模型穿透 UI”现象。当纯 UI 相机以 Overlay 模式叠加到 Base 相机的 RT 上时,如果继续沿用 Base 相机写入的 Depth Attachment,UI 元素的 ZTest 会与 3D 场景的深度发生交叉判定:UI 元素的 NDC.z(通常很小,靠近近平面)会被场景中靠近相机的 3D 模型的深度反向遮挡,导致原本应该浮在最上层的血条、十字准星、对话框被场景对象”穿透”挡住。修复方式有两种:(1) Overlay 相机录制时显式触发一次 ClearDepth(CommandBuffer.ClearRenderTarget(true, false, …));(2) 在 Render Graph 中不绑定 Base 相机的 Depth Attachment,让 Overlay Pass 使用自己的全新深度缓冲(或干脆 ZTest Always 完全不依赖深度)。Custom SRP 通过 CameraSettings 区分 Base / Overlay 角色——Overlay 相机的 SetupPass 应该清除继承的深度。这是真实项目中”UI 突然被场景挡住、查半天找不到原因”的标准答案。

1.6 Scene View 与 Reflection Probe 相机

Custom SRP 在编辑器中还需要处理几种隐式相机:

  • Scene View:编辑器场景视图——开发者每秒看到的画面
  • Reflection Probe:实时反射探针——每帧从 6 个面渲染场景到 cubemap
  • Material Preview:材质球预览
  • Manual Render:用户调用 Camera.Render() 主动触发

这些相机通常没有 CustomRenderPipelineCamera 组件——使用全局默认设置。Scene View 的特殊处理:

1
2
3
4
5
if (camera.cameraType == CameraType.SceneView)
{
ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
useScaledRendering = false; // 编辑器视图不缩放
}

EmitWorldGeometryForSceneView 让 UI 元素(Canvas)在 Scene View 中也能可见——否则 UI 只在 Game View 中渲染。Reflection Probe 的处理则需要避开 PostFX、避开 Render Scale——这些会让反射结果失真。


2. Shader 关键字管理

2.1 关键字爆炸的成本

每个 multi_compile 行会让 Shader 编译出 2 个或更多的 variant。Custom SRP 的 Lit Shader 完整 keyword 列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma multi_compile_instancing
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile _ DIRLIGHTMAP_COMBINED
#pragma multi_compile _ _SHADOW_FILTER_MEDIUM _SHADOW_FILTER_HIGH
#pragma multi_compile _ _CASCADE_BLEND_DITHER
#pragma multi_compile _ _SHADOW_MASK_ALWAYS _SHADOW_MASK_DISTANCE
#pragma multi_compile _ LIGHTS_PER_OBJECT
#pragma multi_compile _ FXAA_QUALITY_LOW FXAA_QUALITY_MEDIUM
#pragma shader_feature _CLIPPING
#pragma shader_feature _PREMULTIPLY_ALPHA
#pragma shader_feature _NORMAL_MAP
#pragma shader_feature _MASK_MAP
#pragma shader_feature _DETAIL_MAP
#pragma shader_feature _EMISSION

排列组合数:2 × 2 × 2 × 3 × 2 × 3 × 2 × 3 × 2⁶ = ~13800 variant——单一 Shader 在最坏情况下可能编译万级 variant。

每个 variant 都意味着:

  • 编译时间:单 variant 编译约 100-500 ms。13800 variant × 200 ms ≈ 46 分钟。修改 Shader 后等编译完是开发体验的灾难
  • Player 包体:每 variant 在 build 中都占用空间,移动端项目尤其敏感
  • 运行时加载:首次切换到新 variant 时引擎需要 JIT 编译或从磁盘加载——产生明显的卡顿尖峰

关键字数量与项目质量正相关、与构建效率负相关——找到收敛点是 TA 的核心工作。

2.2 multi_compile vs shader_feature

两种 pragma 的根本区别:

Pragma 编译时机 运行时启用方式 包体行为
multi_compile 编译期生成所有 variant Shader.EnableKeyword 全局 / material.EnableKeyword per-material 所有 variant 都打入 Player 包
shader_feature 编译期生成所有 variant material.EnableKeyword per-material Player 包仅保留实际被材质使用的 variant

关键洞察:shader_feature 在开发期与 multi_compile 行为一致(编辑器中所有 variant 都可用),但 build 时引擎会扫描所有材质实际启用的 keyword 组合,只把这些组合打包——unused variant 被自动剔除。

实践规则:

  • Code-driven 关键字(由 C# 代码全局开关,如阴影质量、FXAA 等级、Lightmap)→ multi_compile
  • Material-driven 关键字(由材质 inspector 勾选,如透明度模式、Normal Map 启用)→ shader_feature

误用 multi_compile 替代 shader_feature 会让 Player 包体翻倍(Material 实际只用其中几个 variant,但所有 variant 都被打包)。

2.3 收敛策略一:质量等级合并

最有效的关键字收敛是合并多档质量为统一全局质量等级。Note 5 §3.2 已经详细介绍:

合并前(4 个独立 PCF 模式):

1
2
#pragma multi_compile _ _DIRECTIONAL_PCF3 _DIRECTIONAL_PCF5 _DIRECTIONAL_PCF7
#pragma multi_compile _ _OTHER_PCF3 _OTHER_PCF5 _OTHER_PCF7

→ 4 × 4 = 16 排列

合并后(统一 Filter Quality 三档):

1
#pragma multi_compile _ _SHADOW_FILTER_MEDIUM _SHADOW_FILTER_HIGH

→ 3 排列(5.3× 减少)

这种合并的代价:方向光与其他光不能再独立配置质量。但实际项目中两者通常协调一致——独立配置的灵活性收益小于关键字爆炸的工程成本。

2.4 收敛策略二:枚举值代替关键字

某些”配置型”参数可以从关键字改为运行时 uniform 变量。比如 Cascade Blend 从两个关键字(Soft / Dither)改为一个 _CascadeBlendMode uniform:

1
2
3
4
5
6
7
8
9
10
11
12
// 之前:两个 variant
#if defined(_CASCADE_BLEND_DITHER)
// dither blend
#elif defined(_CASCADE_BLEND_SOFT)
// soft blend
#endif

// 之后:一个 variant
if (_CascadeBlendMode == 1)
// dither blend
else if (_CascadeBlendMode == 2)
// soft blend

代价:一次 if-else 分支判断(GPU 上稍微低效)。收益:消除一组关键字。当分支两侧的代码量都很小,且分支判断频率低时,运行时分支比 variant 更划算

2.5 收敛策略三:Stripping 工具链

即使经过收敛,大型项目仍可能有几千 variant。Unity 提供 IPreprocessShaders 接口让构建期主动剔除未使用的 variant:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StripUnusedVariants : IPreprocessShaders
{
public int callbackOrder => 0;
public void OnProcessShader(Shader shader, ShaderSnippetData snippet,
IList<ShaderCompilerData> data)
{
for (int i = data.Count - 1; i >= 0; i--)
{
if (ShouldStrip(data[i]))
data.RemoveAt(i);
}
}
}

典型 stripping 规则:

  • 移动端 build 不需要的高质量 keyword(_SHADOW_FILTER_HIGH)
  • 桌面端 build 不需要的低端 keyword(FXAA_QUALITY_LOW)
  • 项目从未启用的特性(DirectionalLightmap)

精细的 stripping 能让 build 时 variant 数从 5000+ 降到 500-。这是大型项目 production-ready 的必经之路。

💡 配置感知 Stripping(Configuration-Aware Stripping):上述基础 stripping 还是按 Build Target 平台粗筛——但工业界更高级的做法是 直接读取项目的 RP Settings 来驱动剔除。在 OnProcessShader 回调中通过 GraphicsSettings.currentRenderPipeline 拿到当前的 CustomRenderPipelineAsset,进而读取 ShadowSettings.filterQualityPostFXSettings.fxaaQuality 等具体配置——如果项目实际只配置了最大 Medium 等级的阴影,那 _SHADOW_FILTER_HIGH 关键字对应的所有 variant 都可以在 build 期被自动剔除

1
2
3
4
5
6
7
8
9
10
11
12
13
public void OnProcessShader(Shader shader, ShaderSnippetData snippet,
IList<ShaderCompilerData> data)
{
var rpAsset = GraphicsSettings.currentRenderPipeline as CustomRenderPipelineAsset;
var maxShadowQuality = rpAsset.Settings.shadows.maxFilterQuality;

for (int i = data.Count - 1; i >= 0; i--)
{
var keywords = data[i].shaderKeywordSet;
if (keywords.IsEnabled(highShadowKeyword) && maxShadowQuality < High)
data.RemoveAt(i);
}
}

这种”修改 Settings → 自动剔除对应 variant“的闭环让美术 / TA 调整 RP 配置时,build 体积与编译时间自动跟随收缩——不需要手动维护 stripping 规则与配置之间的一致性。这是顶级工程架构的标志:配置即真相、构建感知配置。HDRP 与 URP 都内建了类似机制(ShaderStripping 接口家族),Custom SRP 项目应当在中后期建立类似的自动化层。

2.6 Shader Variant Collection

为了避免运行时首次切换 variant 的卡顿,可以预先 warm up:

1
2
3
4
5
6
7
[CreateAssetMenu(menuName = "Rendering/Shader Variant Collection")]
public class CustomShaderVariantCollection : ShaderVariantCollection
{
}

// 启动时调用
collection.WarmUp();

WarmUp() 强制编译并加载列表中所有 variant,把卡顿前置到 Loading Screen 阶段。Custom SRP 项目通常在游戏启动时 warm up 所有可能用到的 Lit Shader variant——首次场景加载多 1-2 秒,但游戏运行时再无切换卡顿。


3. RP Settings 架构演进

3.1 内联字段的局限

最初的 RP Asset 把所有配置内联在一个文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset
{
[SerializeField] bool useDynamicBatching = true;
[SerializeField] bool useGPUInstancing = true;
[SerializeField] bool useSRPBatcher = true;
[SerializeField] bool useLightsPerObject = true;
[SerializeField] ShadowSettings shadows = default;
[SerializeField] PostFXSettings postFXSettings = default;
[SerializeField] CameraBufferSettings cameraBuffer = default;
[SerializeField] Shader cameraRendererShader = default;
[SerializeField] ColorLUTResolution colorLUTResolution = ColorLUTResolution._32;
[SerializeField] FXAASettings fxaa = default;
[SerializeField] ForwardPlusSettings forwardPlus = default;
// ... 数十个字段
}

随着特性增加,字段数量爆炸。问题:

  • Inspector 滚动疲劳:开发者在 30+ 字段中找特定设置变得困难
  • Git diff 噪音:任何字段修改都会触发 Asset 文件 diff,多人协作时 conflict 频繁
  • 职责混淆:阴影设置与后处理设置在同一文件中,难以独立 review

3.2 拆分为独立 ScriptableObject

3.2.0 简化版本把所有设置拆为独立 ScriptableObject 集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline Settings")]
public class CustomRenderPipelineSettings : ScriptableObject
{
public CameraBufferSettings cameraBuffer = default;
public ShadowSettings shadows = default;
public PostFXSettings postFXSettings = default;
public ForwardPlusSettings forwardPlus = default;
public CameraSettings defaultCameraSettings = default;
public ColorLUTResolution colorLUTResolution = ColorLUTResolution._32;
public Shader cameraRendererShader = default;
}

public class CustomRenderPipelineAsset : RenderPipelineAsset
{
[SerializeField] CustomRenderPipelineSettings settings = default;

protected override RenderPipeline CreatePipeline()
{
return new CustomRenderPipeline(settings);
}
}

或更进一步——每个子系统一个独立 Asset:

1
2
3
4
5
6
7
ProjectSettings/
├── CustomRenderPipelineAsset.asset (引用其他 settings)
├── ShadowSettings_Default.asset
├── PostFXSettings_Default.asset
├── PostFXSettings_LowEnd.asset (针对低端机型的变体)
├── ForwardPlusSettings_Default.asset
└── CameraBufferSettings_Default.asset

这种粒度的好处:

  • 独立调整:阴影美术调 Shadow 不会触碰 PostFX
  • 配置变体:低端机型用 LowEnd 后处理 Asset、旗舰机型用 Default
  • Git 友好:每个文件只在对应职责修改时 diff
  • 运行时切换:高端 / 低端配置可以在 quality preset 中动态切换

3.3 Quality Settings 集成

Unity 的 Quality Settings 系统可以为不同质量等级(Low / Medium / High / Ultra)分别绑定不同的 RP Asset:

1
2
3
4
Quality Level  →  RP Asset  →  Settings Asset
Low → RPAsset_Low.asset → PostFX_LowEnd.asset
Medium → RPAsset_Med.asset → PostFX_Medium.asset
High → RPAsset_High.asset → PostFX_Default.asset

玩家在游戏内切换质量等级时,引擎自动切换到对应的 RP Asset——所有渲染配置(阴影分辨率、PostFX 强度、Forward+ Tile 大小、LUT 精度)一键全切。这是大型项目支持广覆盖硬件的标准做法。


4. 调试工具链

Custom SRP 的调试需要在三套工具间灵活切换。每套工具对应一类问题域。

4.1 Frame Debugger:命令流诊断

Window / Analysis / Frame Debugger——展示帧内所有 GPU 命令的提交序列。

适用问题:

  • 某个 Pass 是否实际被执行
  • 某次 Draw Call 使用的 Material / Shader Variant
  • SRP Batcher 是否生效(”SRP Batch” 节点存在)
  • ClearRenderTarget 是否在预期时机
  • 中间 RT 内容查看(每个节点可单步调试)

局限:

  • 不显示 Render Graph 的资源依赖
  • 不显示原生 GPU 层级行为(tile memory、warp occupancy 等)
  • 大量节点(数百 Draw Call)时翻找困难

实践:当某个对象画错了,第一反应是 Frame Debugger 找到对应 Draw Call → 检查 Shader Variant → 检查输入纹理。

4.2 Render Graph Viewer:资源依赖诊断

Window / Analysis / Render Graph Viewer——展示 Pass 之间的资源读写依赖图。

适用问题:

  • 某个 Pass 是否被引擎裁剪
  • 哪些 Raster Pass 被合并为单个 native render pass(蓝色横条标记)
  • 资源生命周期与 RT 复用情况
  • 资源依赖是否符合预期(Soft Particles 是否正确读到 Depth Copy)

局限:

  • 需要先开启相机的 debug data 收集(性能开销)
  • 编辑器外不可用

实践:当怀疑 Pass 合并优化没生效,或 RT 分配过多时,第一时间打开 Render Graph Viewer 看资源图。Note 1 §7 描述的 TBR 合并优化是否生效完全靠这个工具验证。

4.3 RenderDoc:硬件层诊断

外部工具——抓取整个帧的原生 API 调用序列,包含 D3D12 / Vulkan 层级的所有细节。

适用问题:

  • Compute Shader 的 thread group 实际执行行为
  • Tile memory 占用与 Bandwidth 消耗
  • GPU 端 barrier / sync 时机
  • Driver 层面是否产生意外的 PSO 切换

局限:

  • 学习曲线陡峭,需要熟悉图形 API 细节
  • 抓帧可能改变性能行为(典型放慢 5-10×)
  • 不直接对应 Unity 的高层概念

实践:当 Frame Debugger 与 Render Graph Viewer 都看不出问题、但性能就是不对时,RenderDoc 是最后的 fallback。也是 Note 4 §4.8 描述的 Compute Shader 移动端兼容性问题的主要诊断工具。

4.4 三工具协作模式

flowchart TD
    A[发现性能或视觉问题] --> B{问题类型?}

    B -->|画面错了| C[Frame Debugger
找对应 Draw Call] B -->|Pass 被意外裁剪| D[Render Graph Viewer
检查资源依赖] B -->|性能掉帧但找不到原因| E[RenderDoc
抓帧硬件分析] C --> F[检查 Shader Variant
检查输入纹理] D --> G[检查资源声明
检查 Pass 顺序] E --> H[检查 GPR 占用
检查 Bandwidth] F --> I{解决?} G --> I H --> I I -->|否| J[换一种工具] J --> B style A fill:#fff3e0,stroke:#f57c00 style I fill:#e8f5e9,stroke:#388e3c

工程实践:

  • 日常开发:Frame Debugger 是主力工具,应该熟练到反射式使用
  • 架构调试:Render Graph Viewer 是 RP 重构期的必备工具
  • 性能优化:RenderDoc 是最后阶段的精细化工具

4.5 Profiling Sampler 的命名艺术

每个 Pass 的 ProfilingSampler 名字直接出现在 Frame Debugger 和 Profiler 的 Hierarchy 中——好的命名让调试效率倍增:

1
2
3
4
5
6
7
8
9
// 不好的命名
new ProfilingSampler("Pass1");
new ProfilingSampler("Render");

// 好的命名
new ProfilingSampler("Opaque Geometry");
new ProfilingSampler("Directional Shadows");
new ProfilingSampler("Bloom Pyramid");
new ProfilingSampler("Color LUT Generation");

命名规范建议:

  • 以名词短语开头:标识”是什么”而不是”做什么”
  • 保持稳定:不在循环中拼接 index(”Cascade 0” / “Cascade 1” 在 Profiler 中会被聚合,反而失去对比能力)
  • 跨 Pass 一致:同类工作用同一前缀(”Shadow / …”、”PostFX / …”)

4.6 ProfilingSampler 缓存陷阱

ProfilingSampler 通常缓存为 static readonly 字段:

1
2
3
4
5
6
7
public class GeometryPass
{
static readonly ProfilingSampler
samplerOpaque = new("Opaque Geometry"),
samplerTransparent = new("Transparent Geometry");
// ...
}

但相机的 sampler 不能 static——它依赖 Camera.name 动态生成:

1
2
public ProfilingSampler Sampler =>
sampler ??= new ProfilingSampler(GetComponent<Camera>().name);

这里的 ??= 缓存有一个 editor-only 陷阱:相机改名后缓存的 Sampler 仍带旧名字,Frame Debugger 显示错误。修复方式是 OnEnable 清缓存:

1
2
3
#if UNITY_EDITOR || DEVELOPMENT_BUILD
void OnEnable() => sampler = null;
#endif

这是 Note 1 §5.3 提到过的细节——在工程层面再次强调,因为这是真实项目中容易被遗漏的开发体验细节。


5. RenderingLayer:跨系统的对象-光源过滤

RenderingLayer 是贯穿多个子系统的 32 位掩码——Note 4 §5 介绍了它在光源系统中的角色。在工程层级它的应用更广:

5.1 应用场景

场景 实现方式
Per-Camera 光源屏蔽 cameraSettings.maskLights = true + renderingLayerMask
第一人称武器独立打光 武器对象 + 武器灯光独占 layer
动态/静态对象分组 静态对象一组、动态对象一组,用于 GI 或阴影策略差异
UI 与场景分离渲染 UI 对象一组,过滤掉所有场景光

5.2 数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
Renderer.renderingLayerMask (Renderer 上的 uint32)
↓ 引擎自动写入 unity_RenderingLayer
↓ 通过 UnityPerDraw CBUFFER 传到 Shader
↓ Shader 读取 surface.renderingLayerMask

Light.renderingLayerMask (Light 上的 uint32)
↓ Lighting.SetupLights 写入 OtherLightData.directionAndMask.w
↓ asfloat() 编码 + asuint() 解码(StructuredBuffer 的整数 trick)
↓ Shader 读取 light.renderingLayerMask

Camera.renderingLayerMask (CameraSettings)
↓ FilteringSettings.renderingLayerMask
↓ 影响 RendererList 剔除(哪些对象被渲染)

三层独立但协同——对象的 layer 决定它被哪些 light 照亮、相机的 layer 决定它渲染哪些对象、光源的 layer 决定它影响哪些对象。

5.3 Unity 6 的 RenderingLayerMask 类型

Unity 6 引入了官方的 RenderingLayerMask 类型替代裸 uint:

1
[SerializeField] RenderingLayerMask renderingLayerMask = ~0u;

这个类型在 Inspector 中渲染为下拉多选 UI(类似 LayerMask),用户可以勾选预先定义的命名 layer。命名通过 Project Settings 配置:

1
2
3
4
5
6
Tags and Layers / Rendering Layers
├── Default
├── First Person Weapon
├── UI
├── Static Geometry
└── ...

实践:早期项目用 LayerMask,重构到 Unity 6 时迁移到 RenderingLayerMask——这是 4.0.0 与之后版本的重要 API 变化。


6. TA Takeaway

6.1 关键字管理是项目长期健康的命脉

关键字数量与项目质量正相关、与构建效率负相关——这是 RP 工程层最核心的 trade-off。具体数字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
小项目(< 100 材质):
├─ 单 Lit Shader < 64 variant 是健康范围
├─ 编译时间 < 1 分钟可接受
└─ 关键字策略:以 shader_feature 为主

中型项目(100-1000 材质):
├─ 单 Lit Shader < 256 variant
├─ 必须 stripping
├─ Shader Variant Collection warmup
└─ 关键字策略:multi_compile 与 shader_feature 精确划分

大型项目(> 1000 材质,多平台):
├─ Quality Preset × 设备分级 → 多套 RP Asset
├─ 严格 stripping 规则
├─ Shader Variant Collection 必备
├─ 单 Shader > 1000 variant 需要 review
└─ 关键字策略:质量等级合并、运行时分支替代 variant

每升一级,工程纪律的要求显著提高。项目早期就应该制定关键字预算——而不是在 build 时间从 5 分钟涨到 50 分钟时才被迫处理。

6.2 RP Settings 拆分是规模化的前提

单文件 Settings 在小项目能用、中型项目难受、大型项目不可用。拆分为独立 ScriptableObject 集合的工程红利:

  • 代码所有权清晰:阴影系统的 owner 维护 ShadowSettings、后处理的 owner 维护 PostFXSettings
  • Code Review 粒度合理:每个 PR 只触碰相关 Settings 文件
  • 配置变体管理:通过 Asset Variant 而非 #if 条件编译实现多平台分支
  • 运行时切换效率:直接交换 Settings 引用而不是重新解析整个 Asset

6.3 调试工具链的内功修炼

Frame Debugger / Render Graph Viewer / RenderDoc 三套工具的熟练度直接决定 TA 的工作效率。新手到老手的成长曲线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
新手:
└─ 主要靠肉眼看画面 + console log

进阶:
├─ Frame Debugger 排查具体 Draw Call
└─ 大致理解 Profiler 各项指标

熟练:
├─ Frame Debugger 反射式使用
├─ Render Graph Viewer 验证 Pass 合并
├─ Stats 面板速查 Batches/SetPass/Triangles 三件套
└─ Profiler GPU 模块定位 ms 级瓶颈

精通:
├─ RenderDoc 抓帧分析寄存器占用
├─ Mali Offline Compiler / Adreno GPU Profiler 静态分析
├─ 跨平台行为差异定位(如 Mali vs Adreno 的 Compute Shader 行为)
└─ 能从 ALU / Texture / Bandwidth 三个维度独立判断瓶颈

每个层级都对应不同的问题域。TA 的核心能力之一是知道何时用哪个工具——这个判断本身就需要长期实践积累。

6.4 多相机栈的常见误用

多相机栈的强大常带来误用诱惑。常见反模式:

  • 滥用 Overlay 相机:每个 UI 元素一个独立相机——每相机都是完整 culling + Pass 调度,开销大。正确做法是单 UI 相机 + Canvas 排序
  • Camera depth 链式依赖:相机 A 渲染结果被相机 B 读取、B 又被 C 读取——失去并行机会,调试困难
  • 混用 Game View 与 Reflection Probe 配置:Reflection Probe 不应该有 PostFX、不应该有 FXAA、不应该有 Render Scale——但默认设置常常忘记关闭
  • Scene View 不一致:编辑器 Scene View 用了与 Game View 不同的 PostFX——美术看到的画面与玩家看到的不一样

工程纪律:相机数量应该 ≤ 3(Base + 武器 + UI),超出说明架构有问题。每加一个相机需要专门 review 必要性。

6.5 实践原则

  • CustomRenderPipelineCamera 是相机的标配:默认给每个 Camera 挂上,避免 fallback 逻辑分支爆炸
  • PostFX 在 Reflection Probe / Material Preview 上必须关闭:否则反射结果与编辑器显示都失真
  • 关键字预算项目早期就定:等到 build 慢得受不了再改,重构成本极高
  • shader_feature 比 multi_compile 优先:除非确定关键字需要 C# 全局开关
  • Settings 拆分阈值是 ~10 字段:超过这个数应该考虑独立 ScriptableObject
  • Frame Debugger 应该是肌肉记忆:开发过程中遇到任何渲染问题,第一反应打开它
  • ProfilingSampler 名字精心设计:调试时 5 倍效率
  • RenderingLayerMask 是 Unity 6 的标准:新项目直接用,老项目重构时迁移

系列收束

至此,Custom SRP 的 8 篇笔记完整覆盖了从管线骨架到工程架构的所有核心系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Note 1 · 渲染管线架构与 Render Graph
↓ 执行框架确立
Note 2 · 几何可见性与批次优化
↓ 几何提交链路
Note 3 · 表面着色器与 BRDF
↓ 材质物理基础
Note 4 · 直接光照与 Tiled Forward+
↓ 光照算法核心
Note 5 · 阴影系统
↓ 遮挡与混合
Note 6 · 全局光照与环境
↓ 间接光照接入
Note 7 · 后处理栈
↓ 图像最终加工
Note 8 · 相机系统与工程架构
↑ 工程层规模化

每篇都遵循同一节奏:TL;DR 速览 → 系统全景 → 子模块展开 → TA Takeaway → API 速查。每篇内部又通过 ⚠️ 工程陷阱、💡 工程见解、📱 移动端视角三类 blockquote 标识不同维度的工程价值。

这套笔记的目标读者是已经熟悉基础渲染概念、希望深入理解现代 Unity Custom SRP 工业实现的开发者。它不是教程的搬运——Catlike 教程的代码细节本身极其充分,读者应该作为权威参考;这套笔记是对这些代码细节的提炼、归类、连接、上升——把 33 篇分散的渐进式实现重组为 8 个对应渲染引擎核心系统的知识模块,让读者能:

  • 快速定位:遇到具体问题时知道翻哪一篇
  • 横向连接:理解光照、阴影、GI 之间的数据契约(Surface / Light / BRDF / GI 四个 struct 是核心枢纽)
  • 工程认知:超越教程层面,建立 production 级别的硬件意识、关键字预算意识、调试方法论

愿这套笔记能成为读者深入现代渲染管线工程实践的可靠参考。


What’s Next:基于这套底座可以走向哪里

这套笔记完成的不只是 Custom SRP 的知识梳理——它搭建了一个具有高度扩展性的现代管线底座。Render Graph 的资源声明模型、Compute Shader 的 LUT 范式、Forward+ 的屏幕空间分块剔除框架都不是孤立的实现,而是为承载更复杂的渲染技术准备的工程脚手架。基于这套底座,下面几类高级特性可以极其平滑地接入

GPU 驱动的渲染管线(GPU-Driven Rendering)

当前的几何提交链路(Note 2)仍然是 CPU 端的 Cull → 提交 RendererList → GPU 绘制。GPU-Driven 模式把 Cull 整个搬到 GPU:

  • 所有 Mesh 的 InstanceData 打包到 StructuredBuffer(Note 4 的数据传递模式直接复用)
  • Compute Shader 在 GPU 端做视锥剔除、遮挡剔除、LOD 选择(与 Forward+ Tile 剔除同构)
  • DrawProceduralIndirect + DispatchIndirect 让 GPU 自己决定画什么、画多少

这是 UE5 Nanite、Frostbite GPU-Driven、Activision 的 Geometry Streaming 的核心范式。Render Graph 的资源声明模型天然支持 indirect buffer 作为 RendererList 输入——本套底座只需扩展 Pass 实现即可接入。

体积散射与体积光(Volumetric Scattering)

体积雾、上帝光、舞台烟雾等效果需要在 3D 空间中沿光线步进求积分。现代实现有两条主流路径:

  • 3D Volume Texture + Compute Shader:在屏幕空间分 froxel(frustum voxel)网格,每帧 Compute Shader 填充每个 froxel 的 in-scattering 与 transmittance(Note 5 的 Atlas + Note 7 的 LUT 烘焙思想结合)
  • Ray Marching Pass:在主着色阶段对每像素步进采样体积纹理(Note 4 Forward+ Tile 数据可以加速光源剔除)

Custom SRP 已经具备了 3D 纹理(Color LUT)、Compute Shader(LUT 生成)、屏幕空间分块(Forward+)三大基础设施——接入 Volumetric 主要是新增一个 ComputePass + 一个 Sampling Pass,不需要改动核心架构。

屏幕空间反射 SSR

Note 6 §4.3 提到 Box Projection 失效时的兜底方案就是 SSR。SSR 的实现需要:

  • 屏幕空间深度纹理(Note 7 已有 CopyAttachmentsPass 的 depth copy)
  • HDR 颜色作为反射源(Note 7 的中间 HDR RT)
  • 每像素光线步进(典型 16-32 步)+ Hi-Z 加速

所有依赖资源都已在本套管线中存在——SSR 是一个标准的 Raster Pass,输入两张已有 RT,输出反射颜色,最终在 PostFXPass 之前加性混合到主图。

程序化内容生成(PCG)渲染

PCG(Procedural Content Generation)地形、植被、城市的实时渲染需要把”运行时生成的几何数据”无缝接入渲染管线:

  • 程序生成的 Mesh 数据存入 ComputeBuffer(与 Forward+ 的 OtherLightData 同构)
  • 通过 GPU-Driven 路径自动剔除与提交
  • 光照、阴影、GI 接入复用现有所有系统

随着 UE5 PCG、Unity Splines、Houdini Engine 等工具普及,PCG 接入是下一代游戏管线的标配。Custom SRP 的现代框架天然支持——这是把数据(StructuredBuffer)与提交(RendererList / DrawProceduralIndirect)解耦的红利。

Cluster Forward / Visibility Buffer

Forward+ 的 2D Tile 剔除(Note 4 §4)可以扩展为 3D Cluster——不仅按屏幕分块,还按深度分层。这能进一步降低高密度光源场景下的每像素光源测试数量。再进一步是 Visibility Buffer 路径——Pass 1 写入 instance-id + primitive-id(Note 1 的 Render Graph 资源声明无缝支持),Pass 2 在像素 shader 中按 ID 重建几何属性进行光照。这是 UE5 Nanite 的关键技术之一。

收束

每一条扩展路径都不是”从零搭建”——而是在本套底座上叠加新的 Pass。Render Graph 的声明式架构让这些扩展互相隔离、可测试、可回退。这正是现代 SRP 设计的终极工程价值:渲染特性可以独立演进、累加贡献,而不会让管线代码退化为难以维护的状态机

笔记到这里画上句号——但这套架构能承载的故事才刚刚开始


关键 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 多相机配置
[DisallowMultipleComponent, RequireComponent(typeof(Camera))]
public class CustomRenderPipelineCamera : MonoBehaviour
{
public ProfilingSampler Sampler;
public CameraSettings Settings;
}

// CameraSettings 关键字段
public class CameraSettings
{
public bool copyColor, copyDepth;
public RenderingLayerMask renderingLayerMask;
public bool maskLights;
public RenderScaleMode renderScaleMode;
public float renderScale;
public bool overridePostFX, allowFXAA, keepAlpha;
public PostFXSettings postFXSettings;
public FinalBlendMode finalBlendMode;
}

// Final Blend Mode 配置
public enum BlendMode {
Zero, One, SrcAlpha, OneMinusSrcAlpha,
DstColor, OneMinusDstColor, SrcColor,
// ...
}

// Shader Stripping 接口
public class StripUnusedVariants : IPreprocessShaders
{
public int callbackOrder;
public void OnProcessShader(Shader shader,
ShaderSnippetData snippet, IList<ShaderCompilerData> data);
}

// Shader Variant Collection
[CreateAssetMenu(menuName = "...")]
public class CustomShaderVariantCollection : ShaderVariantCollection
{
void WarmUp();
}

// RP Settings 拆分
public class CustomRenderPipelineSettings : ScriptableObject
{
public CameraBufferSettings cameraBuffer;
public ShadowSettings shadows;
public PostFXSettings postFXSettings;
public ForwardPlusSettings forwardPlus;
public CameraSettings defaultCameraSettings;
public ColorLUTResolution colorLUTResolution;
public Shader cameraRendererShader;
}

// 调试工具入口
// Frame Debugger: Window / Analysis / Frame Debugger
// Render Graph Viewer: Window / Analysis / Render Graph Viewer
// RenderDoc: 外部工具,IBaseHook 集成

// ProfilingSampler 推荐用法
static readonly ProfilingSampler sampler = new("Pass Name");
using var scope = new ProfilingScope(cmd, sampler);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 关键字 pragma
#pragma multi_compile_instancing
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile _ _SHADOW_FILTER_MEDIUM _SHADOW_FILTER_HIGH
#pragma multi_compile _ FXAA_QUALITY_LOW FXAA_QUALITY_MEDIUM
#pragma shader_feature _CLIPPING
#pragma shader_feature _NORMAL_MAP

// RenderingLayer 测试
bool RenderingLayersOverlap(Surface surface, Light light) {
return (surface.renderingLayerMask & light.renderingLayerMask) != 0;
}

// unity_RenderingLayer 来源(UnityPerDraw CBUFFER)
real4 unity_RenderingLayer;
uint renderingLayer = asuint(unity_RenderingLayer.x);