UE5 移动端全 OnePass 渲染管线
概述
在 UE5 移动端渲染管线中实现全 OnePass:主视图的全部光栅阶段(Base/GBuffer → Decals → Lighting → Fog → Translucency → Tonemap)在一次物理 RenderPass 内完成,主 SceneColor/GBuffer 全程保持在 GPU tile memory(Memoryless),永不 Store/Reload。
- 适用平台: Android Vulkan(5/6-subpass 拓扑)+ Android OpenGL ES(FBF barrier 序列 + inline tonemap);Metal 保持现有 framebuffer fetch 路径
- 预期收益: 消除 2~4 次 SceneColor/GBuffer 的 Store/Reload 带宽;TBDR GPU(Adreno/Mali)收益最大
- 核心机制:
- Vulkan: 新增
ESubpassHint复合拓扑 —— Forward 5-subpass / Deferred 6-subpass - GLES: 无 subpass API,同一渲染函数 +
NextSubpass()= non-coherent FBF barrier 序列 +gl_LastFragColorinline tonemap
- Vulkan: 新增
- 改动范围: 21 个文件(Vulkan 版 16 个 + GLES 版 5 个),构建验证中(编译通过,链接等待编辑器关闭)
一、原本管线的痛点
1. MultiPass 下 SceneColor/GBuffer 反复 Store/Reload
移动端默认 Forward 路径在部分平台强制 MultiPass(RequiresMultiPass() 判定):
1 | RenderPass 1: BasePass(写 SceneColor) → Store SceneColor 到主内存 |
Deferred 路径更严重:GBuffer(4~6 个附件)写完 Store、Decals/Lighting 再 Load,Lighting 后 SceneColor 再 Store/Reload 一次。
在 TBDR(Tile-Based Deferred Rendering)GPU 上的代价:每个 Store/Load 都是一次完整的主内存带宽往返。以 1080p RGBA16F SceneColor 为例,一次 Store+Load ≈ 16MB×2 = 32MB 带宽;多 pass 下 SceneColor + GBuffer 合计可达 4~8 次往返,近百 MB 带宽浪费。而 TBDR GPU 的 tile memory 本可以完全避免这些流量 —— 这是移动端最贵的浪费之一。
2. UE 已有 SinglePass 的局限
UE 已经通过 Vulkan SubPass / Metal framebuffer fetch 把 Base+Decals+Fog+Translucency 合并进一个 RenderPass(RenderForwardSinglePass / RenderDeferredSinglePass),但仍有三个结构性缺口:
| 缺口 | 后果 |
|---|---|
ESubpassHint 是固定模板 |
DepthReadSubpass=2 个、DeferredShadingSubpass=3 个、CustomResolveSubpass=3 个 subpass,无法细分出独立的 Fog / Translucency subpass,也无法在 Deferred 里追加 Tonemap |
| Tonemap 是独立后处理 pass | 主 pass 结束后 SceneColor 必须 Store 到主内存,再 Load 回来做 Tonemap/FXAA —— 主 pass 的 Memoryless 收益被最后一步打回原形 |
| Deferred 默认强制 MultiPass | r.Mobile.AllowFramebufferFetch=0(ReadOnly,默认关闭)时 Deferred 直接走 MultiPass,GBuffer 全部落内存 |
3. 已有尝试的缺陷
我们最初的实现(两个 Render*OnePass 包装函数)试图通过多调几次 RHICmdList.NextSubpass() 伪造更多 subpass —— 这是错误用法:
- Vulkan 的 subpass 数量由 RenderPass 创建时的拓扑决定,
NextSubpass()只是前进游标; - 结果 Forward 最多 3 次、Deferred 最多 4 次调用全部越界(Vulkan validation 直接报错);
- 还遗留了一个多余的
}导致整个 Renderer 模块编译失败。
结论:想真正做全 OnePass,必须从 RHI 层扩展 subpass 拓扑,而不是在渲染器层堆调用。
二、我们的修改解决了什么
1. 新增复合 SubPass 拓扑(RHI 层)
在 ESubpassHint 中新增两个枚举,让 Vulkan RenderPass Builder 能构建完整管线拓扑:
Deferred 6-subpass(DeferredFullOnePassSubpass):
1 | SP0 BasePass+GBuffer → SP1 Decals → SP2 Deferred Lighting |
Forward 5-subpass(ForwardFullOnePassSubpass):
1 | SP0 BasePass+Sky → SP1 Decals+ModulatedShadows → SP2 Fog |
2. 关键收益
| 收益 | 说明 |
|---|---|
| SceneColor/GBuffer 全程 Memoryless | 从 SP0 写入到 SP5 读取,全程 tile memory,零主内存往返 |
| Tonemap inline | CustomResolve subpass 读 SceneColor 输入附件直接输出 FinalOutput,后处理链(Bloom/DOF/FXAA)整条跳过 |
| 每阶段独立 subpass | Decals/Fog/Translucency 分开,TBDR tile 内显式 layout/dependency,便于驱动调优 |
| 安全回退 | 能力不足时输出一次性 warning 并回退到原有路径,默认 r.Mobile.OnePass=0 零回归 |
三、如何修改(分层方案)
第 1 层:RHI 枚举(RHI/Public/RHIResources.h)
1 | enum class ESubpassHint : uint8 |
第 2 层:Vulkan RenderPass Builder(VulkanRenderpass.h)
核心改造点:
- 动态 subpass 数量:
BuildCreateInfo()按 hint 生成 1~6 个 subpass,依赖链(srcSubpass/dstSubpass)改用动态索引而非硬编码; - last-attachment resolve:CustomResolve subpass 的输出从硬编码
ColorAttachmentReferences[1]改为ColorAttachmentReferences[NumColorAttachments](最后一个附件)—— 原实现假定只有 SceneColor+FinalOutput 两个附件,Deferred 布局下会写错到 GBufferA; - attachment 预算:主/Decal subpass 用
NumColorAttachments(排除 final output),Lighting 的 GBuffer 输入附件排除 final output; - 强制 classic RenderPass:新 hint 绕过 Dynamic Rendering 路径(
KHR_dynamic_rendering_local_read未实现,dynamic rendering 下NextSubpass只有一条不完整的 memory barrier,无法承载 subpass 语义)。
第 3 层:渲染器函数(MobileShadingRenderer.cpp)
RenderForwardFullOnePass:4 次NextSubpass(),严格匹配 5-subpass 拓扑;RenderDeferredFullOnePass:5 次NextSubpass(),并把ViewFamilyTexture追加为最后一个单采样附件;bFullOnePass判定:
1 | bOnePass = (CVarMobileOnePass != 0) && !bRequiresMultiPass; // 能力不足 → warning + 回退 |
第 4 层:PSO 预缓存一致性
关键陷阱:PSO 预缓存(GetSubpassHint())和运行时 render pass 必须使用同一组判定条件,否则 PSO 按错误 subpass index 创建,Vulkan 直接崩。因此:
MeshPassProcessor.cpp::GetSubpassHint()镜像bFullOnePass判定(CVar + Vulkan + inline tonemap 可用);MobileBasePass.cppTranslucency subpass index:Forward=3 / Deferred=4;MobileDeferredShadingPass.cppLighting hint 跟随 CVar;- 同时放开
SceneUtils.cpp对 Deferred 使用 inline tonemap 的硬排除(OnePass 开启时)。
为什么不做”每帧一个 Pass”
阴影图(光源视角 atlas)、HZB/AO/体积雾(compute/半尺寸)、DBuffer(需在 Base 前就绪)、Opaque FX(GPU sort)在架构上无法进入同一个屏幕空间光栅 RenderPass。TBDR 下”一帧一个 Pass”既不存在也不需要 —— 把最贵的 SceneColor 链压进单 RenderPass 即收益最大化,其余阶段用 Memoryless/前置图节点保证不触碰主 SceneColor。
四、改动清单
RHI 层
| # | 文件 | 改动 |
|---|---|---|
| 1 | RHI/Public/RHIResources.h |
新增 2 个 ESubpassHint 枚举值 |
| 2 | VulkanRHI/Private/VulkanRenderpass.h |
5/6-subpass builder、动态依赖索引、last-attachment resolve |
| 3 | VulkanRHI/Private/VulkanRenderTargetLayout.cpp |
新 hint 最后附件识别为单采样 final target |
| 4 | VulkanRHI/Private/VulkanPipeline.cpp |
各 subpass RT 数量 + resolve subpass RasterizationSamples=1 |
| 5 | VulkanRHI/Private/VulkanRenderTarget.cpp |
新 hint 强制 classic RenderPass |
| 6 | VulkanRHI/Private/VulkanContext.cpp |
NumSamples 校验覆盖新 hint |
| 7 | RHI/Private/RHI.cpp |
depth input-attachment 校验覆盖新 hint |
渲染器层
| # | 文件 | 改动 |
|---|---|---|
| 8 | MobileShadingRenderer.cpp |
两个 FullOnePass 函数 + bFullOnePass + dispatch 三分支 |
| 9 | SceneRendering.h |
成员 + 函数声明 |
| 10 | Engine/Private/SceneUtils.cpp |
OnePass 时允许 Deferred inline tonemap |
| 11 | MeshPassProcessor.cpp |
GetSubpassHint() 镜像判定 |
| 12 | MobileBasePass.cpp |
Translucency subpass index 映射 |
| 13 | MobileDeferredShadingPass.cpp |
Lighting PSO hint 跟随 CVar |
配置与文档
| # | 文件 | 改动 |
|---|---|---|
| 14 | AndroidScalability.ini / IOSScalability.ini |
移除无效 r.Mobile.OnePass=1(SystemSettings 优先级更高会压住 Scalability) |
| 15 | README.md |
全 OnePass 章节 |
五、参考与借鉴
| 来源 | 借鉴点 |
|---|---|
| Epic: Performance and Optimization for Mobile | TBDR 带宽优化原则、Memoryless 标记策略 |
| Epic: Mobile Rendering Features / Shading Modes | Mobile Deferred HDR、Forward/Deferred 选择 |
| UE 源码现有实现 | CustomResolveSubpass(Vulkan inline tonemap)、DeferredShadingSubpass(GBuffer input attachment)的 attachment/dependency 模式 —— 新拓扑是它们的复合扩展 |
| Vulkan Spec: Render Pass / Subpass / Input Attachment | subpass 依赖链(VK_DEPENDENCY_BY_REGION_BIT)、last-attachment 语义 |
| UF2025 Shanghai: 移动端渲染管线优化实战 —《洛克王国:世界》 | SubPass 合并、Deferred HDR 单 Pass 的工程实践方向 |
| ARM: Mali and UE5 Nanite / MegaLights on mobile | Mali GPU tile memory 行为、带宽瓶颈分析 |
说明:具体实现(枚举扩展、builder 改造、PSO 一致性)是本仓库的原创工程,参考了上述文档与 UE 源码中已有 subpass 模式的语义,并非某篇公开文章的复刻。
六、适合什么样的项目
✅ 适合
| 项目特征 | 原因 |
|---|---|
| Android Vulkan 为目标平台 | 原生 5/6-subpass 拓扑,收益最完整 |
| Android GLES + FBF 设备(Adreno 6xx+ / Mali G-series+) | 同一渲染函数 + barrier 序列 + FBF inline tonemap(MSAA≤1) |
| TBDR GPU | Store/Load 带宽是主要瓶颈,OnePass 收益最大 |
| Deferred HDR 高端画质 | 原本 GBuffer 落内存的浪费最大,收益最明显 |
| 对功耗/发热敏感 | 减少主内存带宽 = 直接降低功耗 |
| 不需要完整后处理链 | OnePass 模式跳过 Bloom/DOF/TAA,配合 MSAA 抗锯齿即可 |
❌ 不适合
| 项目特征 | 原因 |
|---|---|
| 重度依赖后处理链(Bloom/DOF/TAA/FXAA 都要) | OnePass 模式只保留 inline tonemap |
| 使用 Distortion / SceneColorCopy 折射材质 / SingleLayerWater 折射 | 需要偏移/邻域采样主 SceneColor,tile-local input attachment 无法提供 |
| GLES 兼容范围广(低端安卓机群) | 无 FBF/PLS 设备只能 MultiPass;PLS 限 128-bit GBuffer |
| 大量 PostOpaque Extensions / 第三方渲染扩展 | 外部扩展可读写 SceneColor/GBuffer,无 subpass 合约 |
| PC / 主机为主 | 桌面端无 FramebufferFetch,MobileOnePass 不生效 |
七、验证方法
构建验证(已完成)
1 | Build.bat UnrealEditor Win64 Development -Module=Engine -Module=VulkanRHI -Module=Renderer |
RenderDoc 抓帧(待真机)
| 关键字 | 含义 |
|---|---|
SceneColorRendering_FullOnePass |
主 OnePass RenderPass |
MobileTonemapSubpass |
最终 Tonemap CustomResolve subpass |
vkCmdNextSubpass |
确认 subpass 数量(Forward=4 次 / Deferred=5 次) |
| attachment 面板 | 确认 SceneColor/GBuffer 为 DONT_CARE store(Memoryless) |
真机
- Android Vulkan 打包(Adreno 6xx+ / Mali G-series+)
stat gpu对比 OnePass 0/1 的 RenderPass 数量与带宽- RenderDoc 无线抓帧验证 subpass 拓扑
八、GLES 全 OnePass(Android OpenGL ES)
GLES 与 Vulkan 的差异
GLES 没有 subpass API —— 全 OnePass 在 GLES 上的形态:
| 概念 | GLES 等效 |
|---|---|
| 一次物理 RenderPass | 一次 framebuffer 绑定 + 连续绘制(TBDR 上即一次 tile pass) |
| 5/6 阶段序列 | 与 Vulkan 同一渲染函数(RenderForwardFullOnePass / RenderDeferredFullOnePass),每次 NextSubpass() = non-coherent FBF barrier |
| input attachment(读当前像素) | GL_EXT_shader_framebuffer_fetch(gl_LastFragColor)/ PLS |
| Memoryless GBuffer | GL_EXT_shader_pixel_local_storage(PLS,128-bit) |
GLES 改动清单(5 个文件)
| # | 文件 | 改动 |
|---|---|---|
| 1 | Engine/Private/SceneUtils.cpp |
IsMobileTonemapSubpassEnabledInline 放开 GLES(FBF 设备);MSAA 收窄为 ≤1(GLES 无 shader MSAA resolve,gl_LastFragColor 只能读当前 sample) |
| 2 | Renderer/Private/MobileShadingRenderer.cpp |
bFullOnePass 判定加入 IsAndroidOpenGLESPlatform && GSupportsShaderFramebufferFetch → GLES 上 dispatch 进入 FullOnePass 函数 |
| 3 | Engine/Shaders/Private/PostProcessTonemap.usf |
FetchAndResolveSceneColor 新增 COMPILER_GLSL_ES3_1 分支:FramebufferFetchES2()(gl_LastFragColor)读 SceneColor |
| 4 | OpenGLDrv/Private/OpenGLRenderTarget.cpp |
RHINextSubpass 的 FBF barrier 覆盖 3 个新 hint(每阶段边界);PLS glEnable/glDisable 覆盖 DeferredFullOnePassSubpass |
| 5 | Renderer/Private/MobileDeferredShadingPass.cpp |
修复 CVar-only bug:PSO 预缓存 bFullOnePass 补上能力门槛,与运行时一致 |
GLES 行为矩阵
| 场景 | 行为 |
|---|---|
| GLES + FBF + MSAA≤1 + OnePass=1 | ✅ 完整全 OnePass:单 framebuffer、5/6 段 barrier 序列、inline tonemap(FBF fetch) |
| GLES + PLS + MSAA≤1 + OnePass=1(Deferred) | ✅ 完整全 OnePass:PLS GBuffer + CopyBuffer + inline tonemap |
| GLES + FBF + MSAA>1 + OnePass=1 | 主链全 OnePass(单 pass),tonemap 段回退独立 AddMobileCustomResolvePass(硬件限制:无 shader MSAA resolve) |
| GLES 无 FBF/PLS | RequiresMultiPass=true → 回退 MultiPass |
九、平台兼容性
| 平台 | 全 OnePass | 说明 |
|---|---|---|
| Android Vulkan | ✅(新 5/6-subpass 拓扑) | 需 maxColorAttachments>=5、AllowFramebufferFetch=1 |
| Android GLES + FBF/PLS | ✅(同一渲染函数 + barrier 序列 + FBF inline tonemap) | MSAA≤1 才 inline;PLS 限 128-bit GBuffer;non-coherent fetch 需 barrier |
| Android GLES 无 FBF | ❌ 回退 MultiPass | — |
| iOS Metal | ❌ 保持现有 framebuffer fetch encoder | Metal 无真实 subpass;inline tonemap 未移植 |
| Desktop PC | ❌ 无效果 | MobileAllowFramebufferFetch 返回 false |
十、已知问题 & 注意事项
- 真机验证未完成:subpass 拓扑/依赖链/GLES FBF barrier 只通过编译器验证,需 RenderDoc + validation layer 真机确认(当前 0 GPU 执行记录)。
- GLES MSAA 限制:
gl_LastFragColor无法做多采样平均(无 VulkanSubpassInputMS等效)→ MSAA>1 时 tonemap 自动回退独立 resolve pass(主链单 pass 收益保留)。 - Deferred shadowed point light:仍走 MultiPass 的 per-light mask 流程(SinglePass 路径本就不支持,非新增回归)。
- Dynamic Rendering(Vulkan):新 hint 强制 classic RenderPass;
KHR_dynamic_rendering_local_read实现后才能解锁。 - 功能降级(与现有 Forward inline-tonemap 路径一致):Distortion / SceneColorCopy 折射材质 / SingleLayerWater 折射不采样主 SceneColor;FXAA 用 MSAA 替代;PostOpaque Extensions 不调用。
- PSO 一致性:
GetSubpassHint()(预缓存)与bFullOnePass(运行时)必须保持同一组判定条件;本次已修复MobileDeferredShadingPass只判 CVar 导致 GLES 预缓存错误 PSO 的 bug。