返回文章列表

UE5渲染路径分析

2026-05-29

新文章

UE 延迟渲染管线分析(PC vs 移动端)

来源:2026-05-25 会话分析,基于 UnrealEngine-release 源码 问题:PC 和移动端的区别 / 影响合批因素 / 渲染路径主要功能


整理完关键源码,给出系统分析。


一、PC 和移动端的核心区别

1. 整体渲染架构

维度PC 延迟(FDeferredShadingSceneRenderer::RenderDeferredShadingRenderer.cpp:1736移动端(FMobileSceneRenderer::RenderMobileShadingRenderer.cpp:1027
GPU 模型假设IMR(立即模式渲染):每次 RT 切换都走 VRAM 带宽TBR/TBDR(基于 tile):tile 内有 on-chip 内存(GMEM/PLS)
Pass 数量大量独立 RenderPass(Depth → BasePass → AO → Lights → Reflections → Fog → Translucency …)力争单 RenderPass + 多 SubpassRenderForwardSinglePass/RenderDeferredSinglePass
RT 数量GBuffer 5-7 张主存 RT,可任意读回GBuffer 只存 tile 内,PassEnd 直接 discard,不落主存

2. 主路径分支

PC 只有一条延迟路径,但内部按特性分裂(Nanite/VSM/Lumen 路径)。移动端有4 条并行路径MobileShadingRenderer.cpp:1027~):

  • RenderForwardRenderForwardSinglePass(默认,依赖 subpass)
  • RenderForwardRenderForwardMultiPass(不支持 FBO Fetch 的设备退化路径)
  • RenderDeferredRenderDeferredSinglePass(PLS 或 Vulkan/Metal 高端设备)
  • RenderDeferredRenderDeferredMultiPass(兼容回退)

移动延迟的核心 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-2243GetColorTargets_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::ProcessMobileBasePass.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 排列)
  • BlendStateRasterizerState(fill/cull)、DepthStencilState
  • ImmutableSamplerState
  • PrimitiveTypebDepthBoundsbAllowVariableRateShadingDrawShadingRate

任一字段不同 → 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 或 MIDPSO + 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 的 PrimitiveHasPrimitiveIdStreamIndex 必须置位才参与 dynamic instancing
NumInstances > 1 的命令MeshDrawCommands.cpp:525 显式要求 NumInstances == 1 才合
静态光照设置差异(LightMap/CSM 排列)选了不同 LightMapPolicy → 不同 shader 排列 → 不同 PSO
不同 LOD 或不同 Section不同 IB 偏移 → 不合批

5. 移动专属的额外断裂源

MobileBasePass.cpp:836-914Process

  • EMobileLocalLightSettingLOCAL_LIGHTS_DISABLED / FORWARD_LOCAL_LIGHTS_ENABLED / ...)会影响 shader 排列。NumMobileDynamicLocalLights 不同的 Primitive 选不同排列。
  • MobileLocalLightsUseSinglePermutation 开启时所有 Primitive 共用一个排列,牺牲 shader 复杂度换合批稳定
  • bCanReceiveCSM 不同 → CSM 排列不同 → 不合批。
  • bMaskedInEarlyPass 决定要不要在早期 Depth 阶段画 → 加入不同 pass。

6. 排序的影响

FMeshDrawCommandSortKeyMeshPassProcessor.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.cpp1736PC 主渲染入口 Render
DeferredShadingRenderer.cpp2384/2396/2905PrePass/Velocity/BasePass 调用
LightRendering.cpp1520PC RenderLights(Clustered + Standard Deferred)
MobileShadingRenderer.cpp1027移动主渲染入口
MobileShadingRenderer.cpp1868 / 2007 / 2265 / 23954 条移动路径
MobileShadingRenderer.cpp2216移动 GBuffer RT 布局
MobileBasePass.cpp836-936移动 BasePass 处理器(光照排列选择)
MobileDeferredShadingPass.cpp1040-1095移动延迟光照体积绘制
MeshPassProcessor.h1222-1287FMeshDrawCommand + MatchesForDynamicInstancing
MeshPassProcessor.h426-450PSO 比较函数
MeshPassProcessor.h1481-1521SortKey 定义
MeshDrawCommands.cpp490-575Dynamic Instancing 折叠逻辑

附录一:IMR vs TBR 深入

1. 不是「容量」是「带宽 / 功耗」

PC (IMR)移动 (TBR)
显存容量8~24 GB GDDR4~16 GB LPDDR(够用)
显存带宽400~1000 GB/s GDDR6/HBM30~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 体现作用
SubpassVulkan VkSubpass多个绘制阶段共享同一个 tile job
Input AttachmentVulkan INPUT_ATTACHMENTSubpass 间读上一阶段 RT,只能读同坐标像素
PLSGLES EXT_shader_pixel_local_storageshader 内声明「数据只活在 tile 内」
Programmable BlendingMetal [[color(n)]] 入参Apple 的等价物
MEMORYLESS AttachmentVulkan 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_OPTIMALSHADER_READ_ONLY_OPTIMAL驱动改元数据,TBR 上是 no-op~0
PS 输出目标变(原 4 RT,现 1 RT)ROP binding 表更新几十 cycle
Subpass 依赖(write→read)同步tile 内 barriertile 级,不走 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 的 barriertile 内同步跟切 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.0GL_EXT_shader_pixel_local_storage扩展,Mali/Adreno 差异
Metal[[color(n)]] 入参 / Programmable BlendingApple 全平台
Vulkan + Android 老设备可能驱动没实现 subpass mergeUE 走 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内容触发条件
ANormal + PerObjectData默认
BMetallic / Specular / Roughness / ShadingModelID默认
CBaseColor + AO默认
D每种 ShadingModel 的自定义数据多数 ShadingModel
EPrecomputedShadowFactors有静态光照
FWorldTangent + AnisotropyClearCoat / Cloth / 各向异性

GBuffer D 共享槽位,根据 ShadingModel 装不同数据:

  • MSM_Subsurface / MSM_PreintegratedSkin: SubsurfaceColor (RGB) + Opacity
  • MSM_SubsurfaceProfile: 只存 Profile ID(散射参数在 CPU 端 Profile asset 里)
  • MSM_ClearCoat: ClearCoat 值 + ClearCoatRoughness + 额外触发 GBuffer F
  • MSM_Hair / Eye / Cloth:各自的数据

GBuffer 增长的真正触发点是「选了哪个 ShadingModel」,不是「采了多少贴图」。

3. 皮肤材质需求映射

需求推荐方案GBuffer 影响
皮肤细节纹理(detail normal / pore / cavity)PS 内混进 Normal / Roughness0
SSS 预计算贴图(curvature / thickness / scatter mask)喂给 MSM_SubsurfaceProfile Opacity 或 Profile LUT0
皮下血管贴图PS 内混进 BaseColor / SubsurfaceColor0
唇部珠光见下表三种路线0 / +1

唇部珠光三种路线

路线实现GBuffer 影响代价
A. 调高光参数唇部更高 Spec + 更低 Rough,叠 anisotropic normal0PS 多几条指令
B. Emissive 高频细节珠光颗粒作为高频 emissive 加到 SceneColor0PS 多几条指令 + 一张贴图采样
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_SubsurfaceProfileMSM_PreintegratedSkincurvature/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 数量 — 这些才是优化重点。