WEBKT

Vulkan Subpass与延迟渲染:如何优雅地实现移动端高效光源裁剪(Light Culling)?

27 0 0 0

在现代移动端游戏开发中,延迟渲染(Deferred Shading)因其光源处理能力而备受青睐。然而,移动端GPU(如ARM Mali、Qualcomm Adreno)大多采用平铺延迟渲染架构(TBDR)。如果在移动端生搬硬套PC端的延迟渲染管线(即:G-Buffer写入显存 -> Compute Shader进行Light Culling -> 绘制光源),会导致恐怖的带宽开销,直接让手机变成“暖手宝”。

为了解决这个带宽痛点,Vulkan引入了 Subpass(子通道) 机制,允许G-Buffer保存在片上高速缓存(Tile Memory/GMEM)中而不写回系统显存。

那么,如何将Vulkan Subpass的高带宽红利与延迟渲染的高效光源裁剪(Light Culling)相结合? 本文将深入探讨这一技术方案的落地细节、架构设计以及必须面对的硬件限制。


一、 Vulkan Subpass 的带宽红利与核心冲突

在进入光源裁剪之前,我们需要明确Subpass的底层逻辑。

在传统的延迟渲染中,G-Buffer(位置/深度、法线、材质参数等)必须写入VRAM。在第二阶段,着色器再从VRAM中采样这些纹理。

而在Vulkan中,通过配置单个 VkRenderPass 下的多个 VkSubpassDescription,并将G-Buffer附件的 usage 设置为 VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT,其 loadOp 设置为 DONT_CAREstoreOp 设置为 DONT_CARE。GPU就会将这些G-Buffer保留在Tile Memory中。下一个Subpass直接通过 Input Attachment(输入附件) 在片上读取它们。

核心冲突:Compute Shader 与 Subpass 的天然绝缘

业界最常用的光源裁剪方案(如 Forward+ 或 Tile-based Deferred)通常依赖 Compute Shader
Compute Shader 在工作时,需要自由寻址(Arbitrary Access)整个屏幕的深度图,并输出一张光源索引列表。
然而,Vulkan的Subpass中无法插入Compute Pipeline!
因为Compute Shader的执行会打破Render Pass的局部性,迫使GPU将当前的Tile Buffer内容(即G-Buffer和深度)完全Flush(写回)到系统显存中。这就意味着:一旦你为了做Compute Light Culling而打断了Render Pass,Subpass省带宽的优势将瞬间荡然无存。


二、 解决方案:如何在 Subpass 架构下进行光源裁剪?

为了既享受Subpass的低带宽,又实现高效的光源裁剪,业界目前主要演化出两种可行方案。

方案 A:基于光源几何体光栅化(Light Volume Rasterization)

这是最经典、最符合Subpass管线直觉的方案。

实现流程:

  1. Subpass 0:G-Buffer 阶段
    正常绘制场景不透明物体,输出G-Buffer到片上输入附件。
  2. Subpass 1:光源着色阶段
    不使用全屏Quad,而是直接绘制光源的3D几何体(例如:点光源绘制球体,聚光灯绘制圆锥体)。
    • 开启加法混合(Additive Blending)
    • 顶点着色器投影光源几何体。
    • 片元着色器通过 subpassInput 读取片上的G-Buffer数据,计算单光照贡献并叠加到帧缓冲区。
// Subpass 1 片元着色器示例
#version 450
layout(input_attachment_index = 0, set = 0, binding = 0) uniform subpassInput inputNormal;
layout(input_attachment_index = 1, set = 0, binding = 1) uniform subpassInput inputDepth;

layout(location = 0) out vec4 outLightAccumulation;

void main() {
    float depth = subpassFetch(inputDepth).r;
    vec3 normal = subpassFetch(inputNormal).rgb;
    
    // 重建世界坐标/视口坐标并计算光照...
    vec3 color = CalculatePointLight(worldPos, normal);
    
    outLightAccumulation = vec4(color, 1.0);
}

裁剪机制:

这种方案利用了GPU的光栅化硬件来进行空间裁剪。只有被光源几何体覆盖的像素才会触发片元着色器。
为了防止相机进入光源内部导致裁剪失效,通常会配合模板测试(Stencil Test)深度边界测试(Depth Bounds Test)

  • 剔除光源背面。
  • 限制仅在场景深度与光源几何体相交的区间内进行Shading。

方案 B:混合管线(帧前 Compute Culling + 帧内 Subpass Shading)

如果你面对的是成百上千个光源,光栅化大量的光源几何体会导致顶点开销暴涨以及严重的Overdraw(重叠绘制像素)。此时,我们需要引入Compute Shader,但要“巧妙地避开”中断Subpass。

实现流程:

该方案将管线拆分为两个独立的Pass,但通过时序错开极低分辨率深度来维持带宽平衡。

  1. Depth Pre-pass(深度前置通道)
    渲染极低分辨率(例如 $1/4$ 或 $1/8$ 缩放)的深度图,或者直接复用上一帧的深度图(基于时间性重投影)。由于分辨率极低,写回显存的带宽开销微乎其微。
  2. Compute Pass(独立于Render Pass之外)
    利用上一步低分辨率深度图,运行 Light Culling Compute Shader。
    将屏幕划分为 $16 \times 16$ 的 Tile Grid,计算每个 Tile 覆盖的光源ID,并将结果写入一个全局的 Light Index Buffer (SSBO)。
  3. Main Render Pass(利用Subpass)
    • Subpass 0:绘制完整分辨率的G-Buffer(保存在Tile Memory)。
    • Subpass 1:绘制一个全屏Quad。在片元着色器中:
      • 读取片上G-Buffer。
      • 根据当前 gl_FragCoord 计算出对应的 Tile 索引。
      • 从预先存放在SSBO中的 Light Index Buffer 中读取当前像素需要计算的光源列表。
      • 循环遍历这些受影响的光源,完成光照计算。
+-------------------------------------------------------------+
| 步骤 1: 渲染低分辨率深度图 / 或是复用上一帧深度                |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
| 步骤 2: Compute Shader 运行 Light Culling                    |
|         输出: Light Index Buffer (SSBO)                     |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
| 步骤 3: 开始主 Render Pass                                   |
|  +-------------------------------------------------------+  |
|  | Subpass 0: 绘制G-Buffer (仅保存在 Tile Memory 中)     |  |
|  +-------------------------------------------------------+  |
|                             | (通过 Input Attachment 传递)   |
|                             v                        |  |
|  +-------------------------------------------------------+  |
|  | Subpass 1: 全屏 Shading                               |  |
|  |            - 读取片上 G-Buffer                         |  |
|  |            - 读取 步骤2 生成的 Light Index Buffer      |  |
|  +-------------------------------------------------------+  |
+-------------------------------------------------------------+

该方案完美融合了Compute Culling的高空间效率与Vulkan Subpass的低存储带宽


三、 Vulkan Subpass 实现光源裁剪的限制条件

在实际落地这套系统时,硬件和Vulkan API层面存在诸多极其严苛的物理限制。如果忽视这些限制,程序不仅无法提效,甚至可能直接Crash。

1. 像素局部性限制(Pixel-Local Constraint)

这是Subpass最核心的物理限制。
在Subpass中,片元着色器通过 subpassInput 读取输入附件时,只能读取当前正在处理的像素点(即当前 $(x, y)$ 坐标)的数据

  • 这意味着什么?
    你不能在Subpass中使用类似 textureOffset 或是读取相邻像素的G-Buffer信息。因此,任何需要邻域像素信息的算法(如:屏幕空间阴影 SSR、SSAO、需要当前帧深度的TAA防抖)都不能直接在拥有Subpass的延迟光照通道中完成。
  • 对光源裁剪的影响
    在Subpass 1中,你只能根据当前像素的G-Buffer数据进行单点求值,光源剔除的逻辑必须在进入片元着色器之前(如光栅化阶段)或通过外部Buffer注入(如方案B的SSBO)来完成。

2. 片上存储容量限制(Tile Budget Limit)

移动端GPU的Tile Buffer物理大小是极其有限的(通常在Mali或Adreno上为每个Tile $128\text{KB}$ 到 $512\text{KB}$ 不等)。
当你在Vulkan中声明多个Color Attachments作为G-Buffer时,它们的**总位宽(Total Pixel Rate Bit-Depth)**会直接影响GPU的硬件行为。

  • 位宽公式
    $$\text{Pixel Bit-Depth} = \text{Format_Size(G-Buffer_0)} + \text{Format_Size(G-Buffer_1)} + \dots + \text{Format_Size(Depth)}$$
  • 惩罚机制
    如果你的G-Buffer总位宽超出了硬件单像素Tile Budget(例如在某些Mali GPU上,超过 128-bit/像素),GPU将无法将整个Tile保留在片上高速缓存中。驱动程序会默默地将Subpass退化为普通的Memory-based Pass,所有的G-Buffer都会被偷偷Flush到显存中,导致性能暴跌。
  • 最佳实践
    在设计G-Buffer格式时必须斤斤计较。推荐配置:
    • G-Buffer 0 (Albedo + Roughness): R8G8B8A8_UNORM (32-bit)
    • G-Buffer 1 (Normal): R10G10B10A2_UNORMR11G11B10_FLOAT (32-bit)
    • G-Buffer 2 (Emission/Specular): R8G8B8A8_UNORM (32-bit,可选)
    • Depth/Stencil: D24_UNORM_S8_UINT (32-bit)
    • 确保总体控制在 128-bit 以内,以获得最佳的跨平台兼容性。

3. Vulkan 屏障与依赖声明的复杂性(Subpass Dependency)

Subpass之间的资源转换不通过普通的 vkCmdPipelineBarrier 触发,而是必须在创建 VkRenderPass 时通过 VkSubpassDependency 显式声明。
如果依赖关系声明错误,会导致严重的硬件管线气泡(Pipeline Bubble)或数据写后读(RAW)冲突。

针对方案A(Subpass 0 写入,Subpass 1 读取),标准的依赖配置如下:

VkSubpassDependency dependency{};
dependency.srcSubpass = 0;                             // G-Buffer 阶段
dependency.dstSubpass = 1;                             // 光照阶段
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
dependency.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
dependency.dstAccessMask = VK_ACCESS_INPUT_ATTACHMENT_READ_BIT; // 关键:指定为输入附件读取
dependency.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT;       // 极其关键:声明为局部区域依赖,启用Tile优化

注意:VK_DEPENDENCY_BY_REGION_BIT 告诉GPU:Subpass 1 只需要读取相同Tile/Pixel区域的Subpass 0数据,这是激活TBDR片上并行优化的金钥匙。

4. 动态光源数量与分支发散(Branch Divergence)

如果你采用方案 B(混合管线),在片元着色器中遍历光源列表时,同一个Tile内的不同像素可能会因为走不同的循环分支而导致着色器发散(Shader Divergence)
在移动端GPU上,大幅度的分支发散会导致SIMD单元执行效率骤降。因此,即使做了Light Culling,循环内部的光照计算代码也应当尽量保持结构简单,避免过多的 if-else 嵌套。


四、 总结与技术选型建议

在Vulkan中结合Subpass和光源裁剪,是一场关于带宽计算开销的权衡游戏。

评估维度 方案 A:光源几何体光栅化 (Subpass Only) 方案 B:混合管线 (Compute + Subpass)
带宽消耗 极低(G-Buffer完全保留在片上) 极低(仅需读写低分辨率深度及轻量SSBO)
光源规模支持 中小规模(50个光源以内,受限于顶点及Overdraw) 大规模(上百个光源,由Compute在片外高效剔除)
实现复杂度 中等(主要在于处理光源相交及模板裁剪) 较高(涉及 Compute 与 Graphics 管线间的同步与内存屏障)
硬件兼容性 极佳(几乎支持所有支持Vulkan的移动设备) 中等(要求设备高效支持 Compute Shader 及 SSBO)
  • 如果你的项目是中轻量级移动端画质,建议采用 方案 A。通过绘制简化的球体/锥体网格,并利用硬件模板测试剔除无效像素,配合Subpass可以实现非常惊艳且低发热的延迟光照。
  • 如果你的项目是硬核3D大作,同屏有数百个动态光源,建议采用 方案 B。用上一帧深度做超前 Compute Culling 获得光源列表,再在Subpass中用全屏Quad一次性完成G-Buffer片上读取与多光源Shading,这是目前移动端图形技术栈的顶峰方案之一。
GraphiXer Vulkan延迟渲染移动端优化

评论点评