UE5 移动端 HZB Occlusion Culling

概述

在 UE5 移动端渲染管线中启用 HZB(Hierarchical Z-Buffer)遮挡剔除,替代传统的 OcclusionFeedback / GPU Query 方案。

  • 适用平台: Android (Vulkan / GLES ES 3.1)、iOS (Metal)
  • 预期收益: 遮挡率高场景 GPU 耗时降低 10-25%,Draw Call 减少 15-30%
  • 延迟: 1 帧(与桌面端一致)
  • 改动范围: 9 个文件,~500 行净增

如何开启

编辑器预览

  1. 编译编辑器后启动
  2. 视口工具栏 → Settings → Preview Rendering Level → 选择 Android VulkanAndroid OpenGL
  3. 控制台输入:
1
2
3
4
5
6
7
r.HZBOcclusion 1          # 开启 HZB Occlusion
r.HZBOcclusion 0 # 关闭(回退到 OcclusionFeedback)
r.HZBOcclusion 2 # 强制模式(忽略所有平台检查)

r.VisualizeOccludedPrimitives 1 # 可视化被剔除物体(绿色线框)
stat rhi # 对比 Draw Call 数量
stat gpu # 对比 GPU 各 Pass 耗时

真机部署

已通过配置文件默认开启:

文件 配置
Engine/Config/Android/AndroidEngine.ini r.HZBOcclusion=1
Engine/Config/IOS/BaseIOSEngine.ini r.HZBOcclusion=1
Engine/Config/BaseDeviceProfiles.ini Android 基类: r.HZB.BuildUseCompute=0
Android_High: r.HZB.BuildUseCompute=1(覆盖为 compute shader)
Android_Preview_Vulkan_SM5: r.HZB.BuildUseCompute=1

注意:AndroidEngine.ini 在编辑器预览模式下不会加载。编辑器预览通过 C++ 默认值 (GHZBOcclusion=1) 生效。


改动清单

核心修改

# 文件 改动
1 SceneVisibility.cpp GHZBOcclusion 默认值改为 1
2 SceneVisibility.cpp !IsOpenGLPlatform() || IsMobilePlatform() 放行移动端 GLES
3 SceneVisibility.cpp bUseOcclusionFeedback 增加 && !bHZBOcclusion,HZB 优先
4 MobileShadingRenderer.cpp ShouldRenderHZB() 增加 r.HZBOcclusion 检查
5 MobileShadingRenderer.cpp 新增 SubmitHZBOcclusionTests(),在 4 处 RenderHZB() 后调用
6 MobileShadingRenderer.cpp 6 处 GPU Query 路径增加 !IsHZBOcclusionEnabled() 互斥
7 SceneOcclusion.cpp FHZBTestPS::ShouldCompilePermutation 放宽到 IsMobilePlatform
8 SceneOcclusion.cpp InitRHI / ReleaseRHI 阈值从 SM5ES3_1
9 SceneOcclusion.cpp Bounds 纹理去掉 TexCreate_RenderTargetable(ES3.1 兼容)
10 SceneRendering.h SubmitHZBOcclusionTests 函数声明 + 类成员
11 SceneTextureReductions.cpp HZBBuildUseCompute() 增加 FeatureLevel 自动检测

非阻塞 Readback 优化

# 文件 改动
12 SceneRendering.h FHZBOcclusionTester 新增 4 槽环形缓存 + bLockCalled
13 SceneOcclusion.cpp MapResults() 改为 IsReady() 预检 + 环形缓存回退
14 SceneOcclusion.cpp UnmapResults() 增加 bLockCalled 保护
15 MetalRenderTarget.cpp Metal RHI RHIMapStagingSurface 非阻塞优化

架构设计

完整帧流程

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
Frame N:
InitViews:
┌─ Visibility ─────────────────────────────────────────┐
│ OcclusionFrameCounter++ │
│ FGPUOcclusion::Configure() → bHZBOcclusion=true │
│ MapResults() → IsReady()? → Lock() 或 环形缓存 │
│ OcclusionCullPrimitive: │
│ IsValidFrame(LastTestFrameNumber)? │
│ → IsVisible(HZBTestIndex) ← 读回结果 │
│ → AddBounds() ← 收集新测试 │
│ UnmapResults() → Unlock() → SetValidFrameNumber(N) │
└──────────────────────────────────────────────────────┘
Render:
┌─ Main RenderPass (Subpass 0→1→2) ───────────────────┐
│ BasePass → Decals → Lighting → Fog → Translucency │
└──────────────────────────────────────────────────────┘
┌─ Post Passes ───────────────────────────────────────┐
│ ResolveDepth │
│ BuildHZB (HZB 深度金字塔) │
│ SubmitHZBOcclusionTests: │
│ UpdateTextures (bounds 上传) │
│ TestHZB (256×256 pixel shader) │
│ EnqueueCopy (GPU→CPU readback) │
│ SSAO / Bloom / Tonemap / FXAA │
└──────────────────────────────────────────────────────┘

关键 CVars

CVar 默认 说明
r.HZBOcclusion 1 (原 0) 0=GPU Query, 1=HZB, 2=强制 HZB
r.HZB.BuildUseCompute 1 0=pixel shader, 1=compute shader
r.OcclusionFeedback.Enable 1 移动端 OcclusionFeedback 系统

HZB 构建模式

预览模式 FeatureLevel HZB 构建
Android OpenGL/Vulkan Low/Mid ES3.1 pixel shader (DownsampleHZB, 8 pass)
Android Vulkan High ES3.1 pixel shader (继承基类)
Android VK SM5 SM5 compute shader (ReduceHZB, 2 pass)
iOS Metal compute shader

非阻塞 Readback 设计

问题

FHZBOcclusionTester::MapResults() 调用 FRHIGPUTextureReadback::Lock() 时,如果 GPU 未完成写入,会阻塞 RenderThread 等待。

方案

利用 FRHIGPUMemoryReadback::IsReady() 进行非阻塞轮询:

1
2
3
4
5
// IsReady() 底层实现:
// OpenGL ES: glClientWaitSync(sync, 0, 0) // 零超时,立即返回
// Vulkan: vkGetFenceStatus(fence) // 非阻塞
// Metal: MTLSharedEvent 轮询
// D3D12: Fence::Poll()

Lock() 行为(IsReady()=true 时调用):

RHI Lock() 是否阻塞
OpenGL ES ImmediateFlush(RHI 命令刷新,不等 GPU)
Vulkan GetMappedPointer() 立即返回
Metal 改前SubmitAndBlockUntilGPUIdle() 无条件阻塞;改后:Fence 已 signal 时跳过,直接 Lock Surface
D3D12 Fence::Wait + Map,Fence 已 signal 时 Wait 不阻塞

Metal 改动(MetalRenderTarget.cpp):

1
2
3
4
5
6
// 改前: 无条件阻塞
RHICmdList.SubmitAndBlockUntilGPUIdle();

// 改后: Fence 已 ready 时跳过
const bool bFenceReady = (FenceRHI != nullptr) && FenceRHI->Poll();
if (!bFenceReady) { SubmitAndBlockUntilGPUIdle(); }
  • GPU 就绪 → Lock() 读回(非阻塞) → 写入环形缓存 → 1 帧延迟
  • GPU 未就绪 → 从环形缓存取最近有效结果 → 2~4 帧延迟但不阻塞

环形缓存

1
2
static constexpr int32 MaxCachedResults = 4;
FCachedResult CachedResults[4]; // { TArray<uint8> Buffer, uint32 FrameNumber }

IsValidFrame(PrimitiveOcclusionHistory->LastTestFrameNumber) 确保只有被缓存帧测试过的 primitive 才会用缓存结果,防止 index 映射错误。


验证方法

编辑器内

1
2
3
4
5
6
7
8
9
10
11
# 1. 确认 HZB 已启用
r.HZBOcclusion # 应返回 1

# 2. 可视化被剔除物体
r.VisualizeOccludedPrimitives 1

# 3. 性能对比
r.HZBOcclusion 0 # 关闭
stat rhi # 记 Draw Call 数
r.HZBOcclusion 1 # 开启
stat rhi # 对比

RenderDoc 抓帧

在 Event Browser 搜索关键字:

Pass 名称 含义
HZB HZB 构建 group
BuildHZB(ViewId=0) HZB 金字塔逐 mip 构建
DownsampleHZB(mip=N) pixel shader mip 降采样
ReduceHZB(mips=[N;M]) compute shader 批处理 mip
SubmitHZB HZB 遮挡测试提交
UpdateTextures bounds 数据 CPU→GPU
TestHZB FHZBTestPS 全屏测试 pass

真机

  1. 打包到 Android/iOS 设备
  2. RenderDoc 无线抓帧
  3. ARM Streamline / Snapdragon Profiler 分析 GPU 耗时
  4. stat unit + stat gpu 对比

平台兼容性

平台 ShaderPlatform HZB Culling HZB 构建
Android GLES ES 3.1 OPENGL_ES3_1_ANDROID pixel shader
Android Vulkan ES 3.1 VULKAN_ES3_1_ANDROID pixel shader
Android Vulkan SM5 VULKAN_SM5_ANDROID compute shader
iOS Metal ES 3.1 METAL_ES3_1_IOS pixel shader
iOS Metal SM5+ METAL_SM5_IOS / METAL_SM6_IOS compute shader
Desktop OpenGL ❌ 保持禁用

已知问题 & 注意事项

  1. 首帧无遮挡: 环形缓存为空时回退到 FirstFrameBuffer(全可见),首帧不剔除。
  2. OpenGL ES readback: ImmediateFlush + glMapBufferRange 在 GLES 上有固有开销,通过 IsReady() 预检 + 环形缓存缓解。
  3. Index 映射: 每帧 primitive→index 映射会变,通过 IsValidFrame(FrameNumber) 确保只有匹配帧的 primitive 使用缓存。
  4. Vulkan ES 3.1: 编辑器预览中 PF_A32B32G32R32F 格式通过去掉 TexCreate_RenderTargetable 兼容。