Vulkan Subpass与延迟渲染:如何优雅地实现移动端高效光源裁剪(Light Culling)?
在现代移动端游戏开发中,延迟渲染(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_CARE,storeOp 设置为 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管线直觉的方案。
实现流程:
- Subpass 0:G-Buffer 阶段
正常绘制场景不透明物体,输出G-Buffer到片上输入附件。 - 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,但通过时序错开或极低分辨率深度来维持带宽平衡。
- Depth Pre-pass(深度前置通道):
渲染极低分辨率(例如 $1/4$ 或 $1/8$ 缩放)的深度图,或者直接复用上一帧的深度图(基于时间性重投影)。由于分辨率极低,写回显存的带宽开销微乎其微。 - Compute Pass(独立于Render Pass之外):
利用上一步低分辨率深度图,运行 Light Culling Compute Shader。
将屏幕划分为 $16 \times 16$ 的 Tile Grid,计算每个 Tile 覆盖的光源ID,并将结果写入一个全局的 Light Index Buffer (SSBO)。 - 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_UNORM或R11G11B10_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,这是目前移动端图形技术栈的顶峰方案之一。