UE5渲染路径分析
新文章
UE 延迟渲染管线分析(PC vs 移动端)
来源:2026-05-25 会话分析,基于 UnrealEngine-release 源码 问题:PC 和移动端的区别 / 影响合批因素 / 渲染路径主要功能
整理完关键源码,给出系统分析。
一、PC 和移动端的核心区别
1. 整体渲染架构
| 维度 | PC 延迟(FDeferredShadingSceneRenderer::Render,DeferredShadingRenderer.cpp:1736) | 移动端(FMobileSceneRenderer::Render,MobileShadingRenderer.cpp:1027) |
|---|---|---|
| GPU 模型假设 | IMR(立即模式渲染):每次 RT 切换都走 VRAM 带宽 | TBR/TBDR(基于 tile):tile 内有 on-chip 内存(GMEM/PLS) |
| Pass 数量 | 大量独立 RenderPass(Depth → BasePass → AO → Lights → Reflections → Fog → Translucency …) | 力争单 RenderPass + 多 Subpass(RenderForwardSinglePass/RenderDeferredSinglePass) |
| RT 数量 | GBuffer 5-7 张主存 RT,可任意读回 | GBuffer 只存 tile 内,PassEnd 直接 discard,不落主存 |
2. 主路径分支
PC 只有一条延迟路径,但内部按特性分裂(Nanite/VSM/Lumen 路径)。移动端有4 条并行路径(MobileShadingRenderer.cpp:1027~):
RenderForward→RenderForwardSinglePass(默认,依赖 subpass)RenderForward→RenderForwardMultiPass(不支持 FBO Fetch 的设备退化路径)RenderDeferred→RenderDeferredSinglePass(PLS 或 Vulkan/Metal 高端设备)RenderDeferred→RenderDeferredMultiPass(兼容回退)
移动延迟的核心 trick 在 MobileShadingRenderer.cpp:2347-2392:
Subpass 0: BasePass → 写 SceneColor + GBuffer A/B/C(/D) 全部到 tile
NextSubpass: Decals → 从 tile 读 GBuffer,改写 GBuffer
NextSubpass: MobileDeferredShadingPass → 从 tile 读 GBuffer,光照写 SceneColor
EndRenderPass → 只把 SceneColor 写主存,GBuffer 当场扔掉
MobileShadingRenderer.cpp:2216-2243 的 GetColorTargets_Deferred 可以看到 MRT 顺序:SceneColor + GBufferA(Normal) + GBufferB(MRS) + GBufferC(BaseColor) + 可选 GBufferD + DepthAux。
PC 的 FDeferredShadingSceneRenderer::Render 是把每个 pass 当独立 RDG pass 提交,依赖 RDG 自动 barrier/fence;移动端是把多个 pass 手工塞进一个 lambda 内调 NextSubpass(),因为驱动需要能识别"这些 subpass 共享一个 tile job"。
3. 光照模型
| 项目 | PC(LightRendering.cpp:1520 RenderLights) | 移动 |
|---|---|---|
| 直接光实现 | Standard Deferred(光体积)+ Clustered Deferred(大量小光源) | 移动延迟:MobileDeferredShadingPass.cpp:1040 RenderDirectionalLights + RenderLocalLight 逐光绘制;可选 UseClusteredDeferredShading |
| 前向多光照 | 不用(除少数 Forward+) | 基础路径:FMobileBasePassMeshProcessor::Process(MobileBasePass.cpp:836-914)根据 NumMobileDynamicLocalLights 选择 EMobileLocalLightSetting 排列,编进 BasePass 像素 shader |
| 阴影 | VSM、RSM、Capsule、SDF、DenoiseRT、Cascade(最多 8 级) | CSM 4 级上限 + RenderModulatedShadowProjections(廉价的乘性叠加) |
| GI/反射 | Lumen(HW/SW RT)、SkyLighting、SSR、ReflectionEnv | 反射捕获 + lightmap 间接光;无 Lumen;MobileSSR 是有限 SSR |
4. 其他差异
- Decal:PC 用 DBuffer(独立 RT,再 BasePass 时混合);移动用
MeshDecal_SceneColorAndGBuffer通过 subpass 内联读 GBuffer 写回。 - Velocity:PC 在 PrePass 后单独画一遍;移动除立体的
RenderStereoMotionVectors外通常不画。 - Tonemap:PC 在 PostProcessing 大流程内;移动可启用
bTonemapSubpassInline,作为 BasePass 最后一个 subpass 在 tile 内完成(MobileShadingRenderer.cpp:1961-1965),省一次全屏带宽。 - 遮挡剔除:PC 主用 HZB + GPU 实例剔除;移动用 HW Occlusion Query,且有
bAdrenoOcclusionMode(高通驱动特殊批处理路径)。 - MSAA:PC 基本被 TAA 替代;移动 MSAA 仍是主力(tile 内 MSAA 几乎免费)。
- Nanite / VSM / DistanceField / 大量 RT 特性:仅 PC。
- Mobile MultiView (XR):
BasePassRenderTargets.MultiViewCount,移动专属。
二、影响合批的因素(PC + 移动通用)
延迟管线下,合批等于"多个 FMeshDrawCommand 折叠成一次 DrawIndexedInstanced"。机制是 Dynamic Instancing,关键函数 MeshDrawCommands.cpp:490-575:按 StateBucketId 排序后,连续同 bucket 且 MatchesForDynamicInstancing 通过的命令合并实例数。
1. PSO 必须一致(FGraphicsMinimalPipelineStateInitializer::operator==,MeshPassProcessor.h:426-450)
PSO 比较的字段:
VertexDeclarationRHI(顶点布局)VertexShaderResource+PixelShaderResource(包括 shader 索引,即 shader 排列)BlendState、RasterizerState(fill/cull)、DepthStencilStateImmutableSamplerStatePrimitiveType、bDepthBounds、bAllowVariableRateShading、DrawShadingRate
任一字段不同 → PSO 不同 → 不合批。
2. MeshDrawCommand 字段必须一致(MeshPassProcessor.h:1274-1287)
bool MatchesForDynamicInstancing(const FMeshDrawCommand& Rhs) const {
return CachedPipelineId == Rhs.CachedPipelineId
&& StencilRef == Rhs.StencilRef
&& ShaderBindings.MatchesForDynamicInstancing(Rhs.ShaderBindings)
&& VertexStreams == Rhs.VertexStreams
&& PrimitiveIdStreamIndex == Rhs.PrimitiveIdStreamIndex
&& IndexBuffer == Rhs.IndexBuffer
&& FirstIndex == Rhs.FirstIndex
&& NumPrimitives == Rhs.NumPrimitives
&& NumInstances == Rhs.NumInstances
&& BaseVertexIndex 一致
&& NumVertices 一致;
}
即必须同一个 IB/VB 偏移段,等价于"几何完全相同"。
3. 资源绑定一致(ShaderBindings.MatchesForDynamicInstancing)
shader 用到的所有 SRV/UAV/Sampler/UB 必须指向相同对象。这意味着:
- 不同材质实例(MID)→ 不同 MaterialUniformBuffer → 不合批(最常见的合批杀手)
- 不同贴图通道但其它都一样 → 不合批
- LightMap 资源不同 → 不合批
4. 实战中常见的合批断裂原因
| 因素 | 为什么打断 |
|---|---|
| 不同 Material 或 MID | PSO + UB 都不同 |
| 蒙皮 vs 静态网格 | VertexFactory 不同 → PSO 不同 |
| 启用 WPO(World Position Offset) | EFVisibleMeshDrawCommandFlags::MaterialUsesWorldPositionOffset 强制走特殊路径;Velocity 也单独画 |
| Cull/Fill 模式不同(双面 vs 单面、Wireframe) | RasterizerState 不同 |
| Blend 模式不同(Opaque/Masked/Translucent) | 各自走不同 pass,且 BlendState 不同 |
| 深度状态不同(写不写深度、CF 函数) | DepthStencilState 不同 |
StencilRef 不同(如自定义模板写入) | 不合批 |
| 透明物体的距离排序 | Translucent SortKey 是按距离的(FMeshDrawCommandSortKey::Translucent.Distance),不同距离 → 不同 bucket,几乎不可能合批 |
| 不支持 GPU-Scene 的 Primitive | HasPrimitiveIdStreamIndex 必须置位才参与 dynamic instancing |
NumInstances > 1 的命令 | MeshDrawCommands.cpp:525 显式要求 NumInstances == 1 才合 |
| 静态光照设置差异(LightMap/CSM 排列) | 选了不同 LightMapPolicy → 不同 shader 排列 → 不同 PSO |
| 不同 LOD 或不同 Section | 不同 IB 偏移 → 不合批 |
5. 移动专属的额外断裂源
MobileBasePass.cpp:836-914 的 Process:
EMobileLocalLightSetting(LOCAL_LIGHTS_DISABLED / FORWARD_LOCAL_LIGHTS_ENABLED / ...)会影响 shader 排列。NumMobileDynamicLocalLights不同的 Primitive 选不同排列。MobileLocalLightsUseSinglePermutation开启时所有 Primitive 共用一个排列,牺牲 shader 复杂度换合批稳定。bCanReceiveCSM不同 → CSM 排列不同 → 不合批。bMaskedInEarlyPass决定要不要在早期 Depth 阶段画 → 加入不同 pass。
6. 排序的影响
FMeshDrawCommandSortKey(MeshPassProcessor.h:1481-1521)决定合批潜力:
union {
struct BasePass { Masked:15, Background:1, PixelShader:32, VertexShader:16; };
struct Translucent { Priority:16, Distance:32, MeshIdInPrimitive:16; };
struct Generic { PixelShader:32, VertexShader:32; };
};
PC/移动 BasePass 的 sort key 首先按 Masked、再按 PSO(PS/VS 哈希)排——目的是让相同 PSO 的命令物理相邻,dynamic instancing 才可能折叠它们。移动还有 GetMobileBasePassSortKey_FrontToBack/_ByState 两种策略(MobileBasePass.cpp:267,307)。
三、渲染路径上的主要功能(按执行顺序)
PC 延迟管线(DeferredShadingRenderer.cpp:1736-3700)
1. OnRenderBegin / GPUScene update
└─ 上传 PrimitiveSceneData / InstanceSceneData 到 GPU 大缓冲
2. Distance Field / Lumen Scene 更新
└─ SDF 体素数据、Lumen Surface Cache
3. Nanite Visibility Begin(如启用)
4. View 初始化 → InitViews
└─ 视锥剔除、HZB GPU 剔除、Light Grid 构建
5. RenderShadowDepthMaps
└─ CSM 级联、点光/聚光阴影、VSM 虚拟阴影、距离场阴影
6. RenderPrePass(可选 DDM_AllOpaque)
└─ Z 预通 → 填满 SceneDepth,BasePass 改为深度只读
7. RenderVelocities(Opaque 阶段)
└─ 运动矢量写入 GBufferVelocity
8. RenderBasePass
└─ GBufferA(法线), B(MRS), C(BaseColor), D(可选), E(选项),
VelocityBuffer, SceneColor.Emissive 全部一次写入
9. Decals(DBuffer 在 BasePass 前;普通 Decals 在 BasePass 后修改 GBuffer)
10. RenderAmbientOcclusion / Lumen Screen Probe Gather
11. RenderLights
├─ AddClusteredDeferredShadingPass(大量小光源批量)
├─ Standard Deferred 光体积绘制(带阴影 / 带 LightFunction 的光)
└─ SimpleLights
12. RenderDeferredReflectionsAndSkyLighting(Lumen Reflection / SSR / SkyLight)
13. SkyAtmosphere / VolumetricCloud / VolumetricFog
14. SingleLayerWater(如有)
15. RenderFog(高度雾、局部雾体积)
16. RenderTranslucency
└─ 排序后逐物体 forward 渲染
17. PostProcessing(Tonemap / Bloom / DoF / MotionBlur / FXAA/TAA/TSR)
18. UI 合成(Slate 输出)
移动延迟管线 SinglePass(MobileShadingRenderer.cpp:2265-2392)
1. GPUScene 更新(如启用)
2. ShadowDepth 单独 RenderPass
└─ CSM(最多 4 级),可选 ModulatedShadow
3. BuildMeshRenderingCommands(CPU 端预构建所有 pass 的 MDC)
├─ DepthPass
├─ BasePass
├─ SkyPass
├─ MeshDecal_SceneColorAndGBuffer
└─ TranslucencyMeshPass
4. 进入唯一一个 SceneColor RenderPass,绑定 SceneColor + GBufferA/B/C(/D) + Depth:
┌─ Subpass 0 ──────────────────────────────
│ - DrawClearQuad(编辑器背景)
│ - RenderMaskedPrePass(Masked 几何提前画 Z)
│ - RenderMobileBasePass:写 SceneColor + GBuffer,
│ • Opaque + Masked,按 LightMapPolicy/LocalLightSetting 排列
│ • SkyPass
│ - RenderMobileDebugView(如启用)
│ - PostRenderBasePass
├─ NextSubpass ────────────────────────────
│ - RenderDecals(从 tile 读 GBuffer,写改写 GBuffer + SceneColor)
├─ NextSubpass ────────────────────────────
│ - MobileDeferredShadingPass(从 tile 读 GBuffer,
│ RenderDirectionalLights + RenderSimpleLights +
│ RenderLocalLight 逐光绘制,结果累加到 SceneColor)
│ - MobileDeferredCopyBuffer(PLS 路径下把 PLS 拷到主存)
│ - RenderFog
│ - RenderTranslucency
│ - RenderOcclusion
├─ NextSubpass(可选) ─────────────────────
│ - RenderMobileCustomResolve(Tonemap inline)
└─ EndRenderPass:GBuffer 当场 discard,只持久化 SceneColor
移动前向 SinglePass(MobileShadingRenderer.cpp:1868-2005)
与延迟的主要区别:BasePass 的 PS 一次性算完所有光照(用 ClusteredLights / ForwardLocalLights 在 shader 内循环),不需要 GBuffer 输出 → 只有 1 个颜色 RT(SceneColor)+ 可选 DepthAux:
┌─ Subpass 0:DepthPrepass + BasePass(完整光照) + SkyPass + DebugView
├─ NextSubpass:Decals + ModulatedShadow + Fog + Translucency + Occlusion
└─ NextSubpass(可选):CustomResolve(Tonemap)
关键源码地图(便于继续追)
| 文件 | 行号 | 内容 |
|---|---|---|
DeferredShadingRenderer.cpp | 1736 | PC 主渲染入口 Render |
DeferredShadingRenderer.cpp | 2384/2396/2905 | PrePass/Velocity/BasePass 调用 |
LightRendering.cpp | 1520 | PC RenderLights(Clustered + Standard Deferred) |
MobileShadingRenderer.cpp | 1027 | 移动主渲染入口 |
MobileShadingRenderer.cpp | 1868 / 2007 / 2265 / 2395 | 4 条移动路径 |
MobileShadingRenderer.cpp | 2216 | 移动 GBuffer RT 布局 |
MobileBasePass.cpp | 836-936 | 移动 BasePass 处理器(光照排列选择) |
MobileDeferredShadingPass.cpp | 1040-1095 | 移动延迟光照体积绘制 |
MeshPassProcessor.h | 1222-1287 | FMeshDrawCommand + MatchesForDynamicInstancing |
MeshPassProcessor.h | 426-450 | PSO 比较函数 |
MeshPassProcessor.h | 1481-1521 | SortKey 定义 |
MeshDrawCommands.cpp | 490-575 | Dynamic Instancing 折叠逻辑 |
附录一:IMR vs TBR 深入
1. 不是「容量」是「带宽 / 功耗」
| PC (IMR) | 移动 (TBR) | |
|---|---|---|
| 显存容量 | 8~24 GB GDDR | 4~16 GB LPDDR(够用) |
| 显存带宽 | 400~1000 GB/s GDDR6/HBM | 30~70 GB/s LPDDR5 |
| 每 bit 访问功耗 | 不敏感(插电) | 极敏感(电池+发热) |
| 每帧"过 DRAM 一次"的代价 | 几乎免费 | 能耗最大头之一 |
移动 GPU 整个架构围绕「尽量不读写主显存」设计 — 不是装不下,是每帧多写一次就多烧一份电。
2. TBR 工作流程(两阶段)
阶段 A:Binning(切箱)
├─ 接收整个 RenderPass 内所有 drawcall 的顶点
├─ 把屏幕切成 16×16 / 32×32 / 64×64 像素的 tile
└─ 给每个 tile 生成"覆盖了哪些三角形"的列表
阶段 B:Rendering(逐 tile)
for each tile:
├─ 把 tile 区域 RT 加载到片上 tile memory(几十~几百 KB)
├─ 在 tile memory 内完成栅格化、深度测试、着色、混合
└─ Resolve:一次性写回 DRAM
核心收益:DRAM 访问从「每 drawcall 都读写」降到「整个 RenderPass 只 load + store 各一次」。
3. 延迟管线在 TBR 上「看起来麻烦」
PC 延迟管线原样搬到 TBR(RenderDeferredMultiPass):
Pass 1: BasePass → tile-by-tile 渲染 → 每个 tile resolve 全 GBuffer 落 DRAM
↓ 5 张 RT × 1080p × 4 字节 ≈ 40 MB 写入
Pass 2: Lighting → 又把 GBuffer 从 DRAM 加载回 tile,采样、光照,SceneColor 落 DRAM
↓ 又是 ~40 MB 读 + 8 MB 写
GBuffer 在 DRAM 上转一圈又回来,纯粹浪费带宽。1080p@60fps 约 4.8 GB/s 的无效带宽,几乎吃掉一半 LPDDR 预算。
所以「GBuffer 不能像 IMR 一样整张 RT 写入」并不准确 — 物理上完全可以(MultiPass 路径就是这么干),只是这么干会让 TBR 优势完全失效。
4. TBR 上真正的解法:GBuffer 永远不出 tile
UE 移动延迟 SinglePass 路径:
单个 RenderPass,5 张 attachment 都标记为 TRANSIENT / MEMORYLESS:
Subpass 0(BasePass): 写 GBuffer A/B/C/D 到 tile memory
Subpass 1(Decals): 从 tile 读 GBuffer → 改写 GBuffer
Subpass 2(Lighting): 从 tile 读 GBuffer → 算光照 → 写 SceneColor
EndRenderPass: 只把 SceneColor 落 DRAM,GBuffer 直接丢弃
| 技术名词 | API 体现 | 作用 |
|---|---|---|
| Subpass | Vulkan VkSubpass | 多个绘制阶段共享同一个 tile job |
| Input Attachment | Vulkan INPUT_ATTACHMENT | Subpass 间读上一阶段 RT,只能读同坐标像素 |
| PLS | GLES EXT_shader_pixel_local_storage | shader 内声明「数据只活在 tile 内」 |
| Programmable Blending | Metal [[color(n)]] 入参 | Apple 的等价物 |
| MEMORYLESS Attachment | Vulkan LAZILY_ALLOCATED | 「这个 RT 不分配 DRAM,只在 tile 存在」 |
InputAttachment 只能读同坐标像素 是关键约束 — 延迟光照恰好满足(逐像素从 GBuffer 取数据)。如果 pass 需要邻域(SSAO/Blur),就必须断 RenderPass。
5. TBR vs TBDR
| TBR(Mali / Adreno) | TBDR(PowerVR / Apple) | |
|---|---|---|
| Binning | ✓ | ✓ |
| Tile memory | ✓ | ✓ |
| 隐面消除时机 | early/late-Z(跟 IMR 一样) | HSR:tile 内所有几何先隐面消除,只对可见像素跑 PS |
| 过度绘制成本 | 仍要交 PS | 几乎为 0 |
| Adreno 特殊 | 「FlexRender」可在 binning 模式和 direct 模式间切换 | — |
这是 iPhone 在重度透明 UI 下功耗远低于 Android 的原因。
6. 衍生影响
- MSAA 在 TBR 上「免费」:子采样数据全在 tile memory,只 resolve 后写 DRAM。移动端 MSAA 仍是主流抗锯齿,PC 已被 TAA 取代。
- MRT 数量受限:tile memory 固定(Mali 一般 16 KB / tile),输出 RT 越多每像素能用 bit 越少 → tile 切小。UE 移动 GBuffer 限制在 4~5 张就是这原因。
- CustomDepth / SceneCapture / 中间读回:都会强制 tile resolve(打断 RenderPass),代价巨大,移动端慎用。
- 设计原则:
vkCmdBeginRenderPass一旦 End 回不去 → 一切能塞进一个 RenderPass 就塞。
附录二:Subpass 成本模型 & 抗锯齿取舍
1. Subpass 之间「切换 GBuffer」其实没切
「切换」只是元数据级别,数据从头到尾在 tile memory 没动过:
| 表面看到的「切换」 | 实际硬件发生 | 成本 |
|---|---|---|
Attachment layout 转换 COLOR_ATTACHMENT_OPTIMAL → SHADER_READ_ONLY_OPTIMAL | 驱动改元数据,TBR 上是 no-op | ~0 |
| PS 输出目标变(原 4 RT,现 1 RT) | ROP binding 表更新 | 几十 cycle |
| Subpass 依赖(write→read)同步 | tile 内 barrier | tile 级,不走 DRAM |
跟 RenderPass 切换对比:
切 Subpass: tile 内 barrier,几十 cycle // 微观
切 RenderPass: store 所有 RT 到 DRAM + 下个 pass load 回 tile
≈ 几十 MB 带宽 + 几百 us // 宏观
Subpass 切换比 RenderPass 切换便宜 1000 倍以上。
会让 subpass 失效的情况:
- 驱动不支持 → UE 退化 MultiPass
- 某 subpass 需要邻域采样(SSAO/Blur) → InputAttachment 做不了 → 强制断 RenderPass
- MRT 数量太多 / 格式太宽 → tile 装不下 → 驱动 spill 部分 attachment 到 DRAM
2. TAA 残影是真问题 — UE5 用 TSR 缓解
PC 抛弃 MSAA 不是因为 TAA 好,是因为 MSAA 也不够用了。
MSAA 的死穴
| 走样来源 | MSAA 能解吗 |
|---|---|
| 三角形边缘 | ✓ |
| Specular highlights(高光闪烁) | ✗(PS 内算,MSAA 在 PS 之外采样) |
| 法线贴图细节走样 | ✗ |
| Alpha-tested 几何(树叶) | 半残(需 A2C,效果一般) |
| 延迟管线 + MSAA | 几乎不可用(GBuffer × N sample,带宽炸,需 per-sample lighting) |
最后一条对延迟管线最致命 — PC 延迟时代基本告别 MSAA,只剩 TAA/SMAA/FXAA/TSR/DLSS。
TAA 残影根源
TAA 本质 当前帧 = α × 当前采样 + (1-α) × 上一帧 reproject 像素。残影来自**「上一帧对应像素」找错或找不到**:
- Disocclusion:墙后突然出现物体,新区域没历史 → reproject 拿到墙的颜色 → 拖影
- 透明物体没写 velocity:粒子、玻璃,motion vector 为 0 → 鬼影
- WPO / 顶点动画 / 蒙皮:velocity pass 没单独画则丢失
- 快速旋转的细节(车轮、风扇):velocity 算的是线性位移 → 错
- 历史置信度难判断:UE 用 neighborhood clamp,松了出鬼影紧了出闪烁
UE5 的 TSR
- 更激进的历史置信度(per-pixel + 频域分析)
- 历史 invalidation:检测到 disocclusion 立即丢弃历史
- 低分辨率渲染 + 时序重建到高分辨率(类似 DLSS 但纯软件)
- 残影显著比 TAA 少但不是 0
DLSS / FSR / XeSS / TSR 都是 TAA 谱系进化。
移动端的选择
- 移动:MSAA 几乎免费(tile 内多 sample),UE 移动仍主推 MSAA(
MobileMSAA)。延迟 + MSAA 也能用但 GBuffer tile 内存翻倍,实际更倾向移动前向 + MSAA。 - PC:延迟 + MSAA 不可行,只能 TAA/TSR/DLSS,残影是已知代价。
附录三:PC 为什么不用 Subpass / Subpass 为什么会「不支持」
1. PC 为什么不用 — subpass 的价值全在 tile memory 上
PC 没有 tile memory,只有 VRAM。Subpass 在 PC 上没有任何性能收益:
| TBR(移动) | IMR(PC) | |
|---|---|---|
| Subpass 之间数据存哪 | tile memory(片上几十 KB) | VRAM(跟 RenderPass 没区别) |
| 切 subpass 的 barrier | tile 内同步 | 跟切 RenderPass 一样,走 cache flush |
| 跨 subpass 读上一阶段输出 | tile fetch 指令(单 cycle) | 跟普通 texture sample 一样,走 L2 → VRAM |
NVIDIA/AMD Vulkan 驱动里,subpass 内部就是当独立 RenderPass 实现。
更糟:InputAttachment 的「只能读同坐标像素」限制,在 PC 上是 downgrade。PC 上 GBuffer 是普通 RT,可以 bilinear / 邻域 / stochastic 采样;用 subpass 就锁死成逐像素 — 收益为 0,灵活性砍了。
PC 实际在反向走:抛弃 RenderPass
- Vulkan 1.3
VK_KHR_dynamic_rendering:不再需要声明 RenderPass / Subpass,直接vkCmdBeginRendering,ResourceBarrier 控同步 - DirectX 12 从来没有 RenderPass + Subpass — 只有 ResourceBarrier
- Metal 在 Apple Silicon(TBDR)保留 RenderPass + PLS
- UE5 PC 路径越来越多走 dynamic rendering(
r.Vulkan.DynamicRendering=1)
RenderPass + Subpass 是 Vulkan 当年为兼容移动 TBR 的妥协,PC 厂商推动加 dynamic rendering 绕过它。
2. Subpass 为什么会「不支持」— 不是业务能控制的事
Subpass 是意图声明,真正执行靠驱动 + 编译器 + 硬件三方协作。业务只能说「我希望这些 pass 共享 tile」,具体怎么实现一概管不了。
环节 1:硬件 tile memory 容量
Tile memory 是片上 SRAM,容量物理固定(8 KB ~ 768 KB)。GBuffer:
5 张 RGBA8(4 byte) + Depth/Stencil(5 byte) = 25 byte/pixel
16×16 tile = 256 px × 25 = 6.4 KB / tile
GPU 只有 8 KB tile memory 就装不下。驱动会:
- A) 缩小 tile(16×16 → 8×8 / 8×4),边界开销暴增
- B) 强制 spill 部分 attachment 到 DRAM,subpass 退化
- C) 直接报告不支持,UE 走 MultiPass
Mali G31 / Adreno 506 这类入门 GPU 物理装不下完整 GBuffer。
环节 2:Shader 编译器要把 InputAttachment 编译成「tile fetch」指令
layout(input_attachment_index=0, binding=0) uniform subpassInput uGBufferA;
vec4 normal = subpassLoad(uGBufferA); // 编译成什么?
- 正确实现:
LD_TILE(Mali)/ldib(Adreno)→ 单 cycle 从 tile 读 - 老/差驱动:编译成
TXF(普通 texel fetch)→ 走 L2 → VRAM
API 层显示「subpass 支持 = true」,实际带宽完全不对 — 高通早期 Adreno 4xx/5xx 臭名昭著。业务代码完全无能为力。
环节 3:驱动 RenderPass 编排
驱动要:
- 决定 tile 大小(根据所有 attachment 总 bit 数)
- 决定哪些 attachment 标
TRANSIENT不分配 DRAM - 多个 subpass 合并成一个 tile job
- subpass 间插 tile 内 barrier(不是 DRAM barrier)
任何一步 bug 都导致「假支持」。Android OEM 改驱动改出 bug 是常态。
环节 4:不同 API 上的形态
| API | 机制 | 兼容性 |
|---|---|---|
| Vulkan 1.0+ | VkSubpassDescription + INPUT_ATTACHMENT | 标准支持,驱动质量参差 |
| OpenGL ES 3.1+ | GL_EXT_shader_framebuffer_fetch(读自己 RT) | 扩展,非强制 |
| OpenGL ES 3.0 | GL_EXT_shader_pixel_local_storage | 扩展,Mali/Adreno 差异 |
| Metal | [[color(n)]] 入参 / Programmable Blending | Apple 全平台 |
| Vulkan + Android 老设备 | 可能驱动没实现 subpass merge | UE 走 MultiPass |
UE 启动检查 cap flag(bSupportsShaderFramebufferFetch / bSupportsPixelLocalStorage / Vulkan subpass support),根据结果选 SinglePass 还是 MultiPass — 这就是 MobileShadingRenderer.cpp 4 条路径存在的原因。
3. 关键认知
业务能保证「调用顺序」,但 subpass 保证的不是顺序,而是「A 的输出物理上不落 DRAM,B 直接从 tile memory 读」。这只有硬件能做到:
- 业务只能告诉驱动「我的画法符合 subpass 模式」(意图声明)
- 驱动必须翻译成「正确 tile 调度 + 正确指令选择 + 正确 attachment 分配」
- 硬件必须有足够 tile memory 和对应指令支持
任何一层不行,只能 fallback 成「分两个 RenderPass + 走 DRAM」。
类比:你跟编译器说「这变量请放寄存器」,编译器可能因为寄存器不够 / ABI 约束 spill 到栈上 — 你没办法强制。
附录四:角色皮肤材质与 GBuffer 影响
1. 关键认知:输入贴图 ≠ GBuffer 输出
采样多少张贴图,跟 GBuffer 输出多少张 RT 完全无关。贴图是 BasePass PS 的输入,会被混合/计算后塞进 BaseColor / Normal / Roughness 等输出通道。
BasePass PS:
采样 12 张贴图(diffuse, normal, detail-normal, detail-spec,
curvature, thickness, blood-vessel, lip-mask,
pearl-flake, ao, cavity, microstructure)
↓ shader 内计算
输出:GBuffer A(normal) / B(MRS) / C(BaseColor) / D(SSS) / SceneColor(emissive)
GBuffer 数量不变,变化的是 PS 指令数、寄存器占用、贴图采样带宽。
2. 真正影响 GBuffer 数量的是 ShadingModel
| GBuffer | 内容 | 触发条件 |
|---|---|---|
| A | Normal + PerObjectData | 默认 |
| B | Metallic / Specular / Roughness / ShadingModelID | 默认 |
| C | BaseColor + AO | 默认 |
| D | 每种 ShadingModel 的自定义数据 | 多数 ShadingModel |
| E | PrecomputedShadowFactors | 有静态光照 |
| F | WorldTangent + Anisotropy | ClearCoat / Cloth / 各向异性 |
GBuffer D 共享槽位,根据 ShadingModel 装不同数据:
MSM_Subsurface/MSM_PreintegratedSkin: SubsurfaceColor (RGB) + OpacityMSM_SubsurfaceProfile: 只存 Profile ID(散射参数在 CPU 端 Profile asset 里)MSM_ClearCoat: ClearCoat 值 + ClearCoatRoughness + 额外触发 GBuffer FMSM_Hair / Eye / Cloth:各自的数据
GBuffer 增长的真正触发点是「选了哪个 ShadingModel」,不是「采了多少贴图」。
3. 皮肤材质需求映射
| 需求 | 推荐方案 | GBuffer 影响 |
|---|---|---|
| 皮肤细节纹理(detail normal / pore / cavity) | PS 内混进 Normal / Roughness | 0 |
| SSS 预计算贴图(curvature / thickness / scatter mask) | 喂给 MSM_SubsurfaceProfile Opacity 或 Profile LUT | 0 |
| 皮下血管贴图 | PS 内混进 BaseColor / SubsurfaceColor | 0 |
| 唇部珠光 | 见下表三种路线 | 0 / +1 |
唇部珠光三种路线
| 路线 | 实现 | GBuffer 影响 | 代价 |
|---|---|---|---|
| A. 调高光参数 | 唇部更高 Spec + 更低 Rough,叠 anisotropic normal | 0 | PS 多几条指令 |
| B. Emissive 高频细节 | 珠光颗粒作为高频 emissive 加到 SceneColor | 0 | PS 多几条指令 + 一张贴图采样 |
| C. 真 ClearCoat 双层 | 切到 MSM_ClearCoat,第二层模拟镀膜 | +1(触发 GBuffer F) | 显存带宽 + 跟 SubsurfaceProfile 互斥 |
关键陷阱:ClearCoat 和 SubsurfaceProfile 互斥(一个材质实例只能选一个 ShadingModel)。想同时要 SSS + 双层高光:
- 用「From Material Expression」按像素切换 ShadingModel(UE 5+,但 GBuffer D 按像素打包不同内容)
- 唇部拆成单独材质 / mesh section,各走各的(多 drawcall,无法合批)
- 用方案 A/B 在 SubsurfaceProfile 路径下「假装」珠光
4. 真正要担心的不是 GBuffer 数量
VGPR 寄存器压力 → Occupancy 下降
高质量皮肤 PS 通常 100+ 指令、12+ 贴图采样。寄存器占用一高,occupancy 掉,遮不住贴图采样延迟,实际比指令数预估的差得多。NVIDIA 64 VGPR 是临界点。
SubsurfaceProfile 触发屏幕空间 SSS Blur Pass
PostProcessSubsurface.cpp 在光照后插入:
RenderLights → SSS Setup → SSS Horizontal Blur → SSS Vertical Blur → Recombine
每个 blur 都是全屏 PS,带宽不小。移动端基本不开,退到 MSM_PreintegratedSkin LUT 近似。
材质 Permutation 爆炸
每个 ShadingModel + LightingPolicy + Feature 组合都是一个 permutation,SSS + ClearCoat + Hair 同时存在 shader cache 容易膨胀到 GB 级,PSO 编译卡顿。
移动端约束
- GBuffer 硬上限 4~5 张 → ClearCoat 在移动延迟基本不可用
- SSS 屏幕空间 blur 太贵 → 移动端退到 PreintegratedSkin 或自定义 BRDF 拟合
- PS 堆太多贴图采样容易超 tile budget 导致 spill
5. 推荐组合
| 部位 | PC ShadingModel | 移动 ShadingModel | 实现要点 |
|---|---|---|---|
| 脸/身皮肤 | MSM_SubsurfaceProfile | MSM_PreintegratedSkin | curvature/thickness/血管贴图在 PS 内混进 BaseColor + Opacity |
| 唇部 | 同上,用方案 A/B 实现珠光 | 同上 | 单独 lip mask + 高频 spec,避免切 ClearCoat |
| 眼睛 | MSM_Eye | 自定义 BRDF | 独立 mesh section |
| 头发 | MSM_Hair | 自定义各向异性 | 独立 mesh section |
这套组合 PC 上 GBuffer 数量保持在「正常延迟 + GBuffer D 给 SSS」,不会额外多 RT。 代价在 PS 复杂度、SSS blur pass、shader permutation 数量 — 这些才是优化重点。