把G-Buffer留在Tile-Memory里:移动端TBDR延迟渲染管线优化实践
在PC端,延迟渲染(Deferred Shading)早已是主流引擎的标配。然而,当开发者尝试将这一整套方案直接搬到移动端时,往往会遭遇严重的性能滑铁卢。设备发热降频、帧率腰斩,罪魁祸首通常只有一个:带宽暴涨。
移动端GPU(如Mali、Adreno、Apple Silicon)普遍采用**TBDR(Tile-Based Deferred Rendering)**架构。要在这类架构下跑通高效的延迟渲染,核心思想只有一个:利用芯片上的高速Tile Memory(SRAM),绝不让G-Buffer的数据格式化写入系统显存(VRAM)。
为什么PC延迟渲染在移动端会“翻车”?
我们先算一笔带宽账。
假设在1080P(1920×1080)、60 FPS的配置下,设计一个常规的PC级G-Buffer方案:
- RT0 (Albedo): RGBA8 (4字节)
- RT1 (Normal): RGBA16F (8字节)
- RT2 (Material/Spec): RGBA8 (4字节)
- RT3 (Depth): D24S8 (4字节)
每个像素的G-Buffer共占用 20 字节。
- 写入带宽(Base Pass):$1920 \times 1080 \times 60 \times 20 \text{ 字节} \approx 2.49 \text{ GB/s}$
- 读取带宽(Lighting Pass):$1920 \times 1080 \times 60 \times 20 \text{ 字节} \approx 2.49 \text{ GB/s}$
仅G-Buffer的读写就消耗了约 5 GB/s 的带宽。移动端设备的物理总带宽通常只有 20GB/s ~ 40GB/s,且需要CPU与GPU共享。如果再加上纹理采样、后处理和阴影贴图,带宽开销将直接触及硬件红线,导致芯片迅速过热并降频。
TBDR的救星:片上瓷砖内存(Tile Memory)
与PC端GPU直接读写VRAM不同,TBDR架构将屏幕划分为一个个 $16 \times 16$ 或 $32 \times 32$ 像素的Tile。
在渲染时,GPU在片上高速SRAM(Tile Memory)中分配一块区域,本地完成几何体光栅化和像素着色,最后只将最终的Color Buffer写回VRAM。
如果我们能将G-Buffer的生命周期限制在Tile内——即在Tile内写入G-Buffer,在Tile内读取G-Buffer进行光照计算,最后丢弃G-Buffer,只将光照结果写回VRAM。那么,上述 5 GB/s 的带宽消耗就会直接降为 0。
为了实现这一目标,我们需要利用现代图形API(Vulkan、Metal、OpenGL ES)提供的高级特性。
高效延迟管线设计:API落地实践
不同的图形API对Tile Memory的控制机制有所不同。以下是主流API的具体实现逻辑。
1. Vulkan:利用 Subpass 与 Input Attachments
Vulkan通过 Render Pass 和 Subpass(子通道) 机制完美契合了TBDR架构。我们将延迟渲染分为两个Subpass,并放在同一个Render Pass中执行。
- Subpass 0 (Base Pass): 渲染G-Buffer。
- Subpass 1 (Lighting Pass): 读取G-Buffer进行光照计算。
关键配置 A:VkAttachmentDescription 的生命周期控制
要阻止G-Buffer写回VRAM,必须正确设置 storeOp 和内存标志:
// 针对 G-Buffer 各个 Attachment (Albedo, Normal 等) 的配置
VkAttachmentDescription gBufferAttachment{};
gBufferAttachment.format = VK_FORMAT_R8G8B8A8_UNORM;
gBufferAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
gBufferAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 开始时清空
gBufferAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 关键:计算完即丢弃,不写回VRAM
gBufferAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
gBufferAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
gBufferAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
gBufferAttachment.finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
关键配置 B:使用 Lazily Allocated Memory
用于承载G-Buffer的 VkImage 应该分配延迟内存,告诉驱动这块显存可能根本不需要物理分配:
VkImageCreateInfo imageInfo{};
imageInfo.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT | VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT;
// TRANSIENT_ATTACHMENT_BIT 告诉驱动该图像是临时的
在分配内存时,首选带有 VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT 属性的内存类型。在很多现代移动GPU上,这会使G-Buffer实际占用的物理显存变为0。
关键配置 C:Subpass 依赖与 Input Attachment
在Shader中,不能使用普通的 sampler2D 去采样G-Buffer,而必须使用 subpassInput。
GLSL 示例:
layout(input_attachment_index = 0, set = 0, binding = 0) uniform subpassInput inputAlbedo;
layout(input_attachment_index = 1, set = 0, binding = 1) uniform subpassInput inputNormal;
void main() {
vec3 albedo = subpassLoad(inputAlbedo).rgb;
vec3 normal = subpassLoad(inputNormal).rgb;
// 光照计算...
}
subpassLoad 会编译为直接从Tile Memory读取数据的硬件指令,避开了Texture Unit和VRAM。
2. Metal:利用 Programmable Blend 与 Threadgroup Memory
在Apple的Metal框架中,可以通过 Tile Shaders 或 Programmable Blend 直接访问当前像素在本地Imageblock中的数据。
当定义 Render Pipeline 时,将G-Buffer的 storeAction 设置为 .dontCare:
let renderPassDescriptor = MTLRenderPassDescriptor()
// 最终输出的 Color Buffer
renderPassDescriptor.colorAttachments[0].texture = mainColorTex
renderPassDescriptor.colorAttachments[0].storeAction = .store
// G-Buffer 临时 Attachments
renderPassDescriptor.colorAttachments[1].texture = gBufferNormalTex
renderPassDescriptor.colorAttachments[1].storeAction = .dontCare // 绝不写回
在MSL(Metal Shading Language)中,光照着色器可以直接将G-Buffer作为输入参数传入:
struct GBufferData {
half4 albedo [[color(0)]];
half4 normal [[color(1)]];
};
fragment half4 fragment_lighting(GBufferData gBuffer [[stage_in]],
constant LightData& lightData [[buffer(0)]]) {
half3 color = gBuffer.albedo.rgb * saturate(dot(gBuffer.normal.rgb, lightData.dir));
return half4(color, 1.0);
}
3. OpenGL ES:利用 Pixel Local Storage (PLS) 或 Framebuffer Fetch
在旧项目或对GLES有兼容需求的场景下,可以使用以下扩展:
EXT_shader_framebuffer_fetch:允许在Fragment Shader中直接读取当前正在写入的Framebuffer绑定的颜色值。ARM_shader_framebuffer_fetch_depth_stencil:允许直接读取深度。
GLES Shading Language 示例:
#extension GL_EXT_shader_framebuffer_fetch : require
// 此时 gl_LastFragData 存储了上一阶段写入该像素的值
void main() {
vec4 albedo = gl_LastFragData[0];
vec4 normal = gl_LastFragData[1];
// 直接在本地进行光照叠加
gl_FragColor = albedo * max(dot(normal.xyz, lightDir), 0.0);
}
移动端延迟管线设计的避坑指南
尽管API提供了支持,但在实际研发中,以下几个设计细节直接决定了管线的成败:
1. 极致压缩的 G-Buffer 布局
Tile Memory的大小是有上限的(通常每个Pixel分配的预算在128-256 bit之间)。如果G-Buffer过大,硬件会自动发生 Tile Spill(分块溢出),强制将数据写回VRAM,导致优化前功尽弃。
建议将G-Buffer压缩在 128 bit (16字节) 以内。推荐设计:
- RT0 (Albedo.rgb, Roughness):
RGBA8(4字节) - RT1 (Normal.xy in Octahedron, Metalness, Cavity):
RGBA8(4字节) — 采用八面体编码(Octahedral Conjugate)将法线压缩至2通道。 - RT2 (Emission, Occlusion, Extra):
RGBA8(4字节) - Depth:
D24S8(4字节) — 在Lighting Pass中直接通过重建世界坐标使用。
2. 光源裁剪(Light Culling)的策略
延迟渲染的优势在于多光源。在移动端,不能使用简单的全屏Quad去绘制光源,这会导致严重的无用像素着色(Pixel Overdraw)。
- 不要用Geometry Volume(如光源球体):在移动端,绘制大量光源球体并进行深度/模板测试会带来不可忽视的DrawCall和顶点开销。
- 推荐 Tiled / Clustered Deferred:在CPU端或Compute Shader中进行光源分块裁剪。将光源索引列表存储在Buffer中,Lighting Pass直接根据当前像素的Tile坐标读取相交的光源列表,在单次Pass内完成全部光照。
3. 半透明物体的处理(Forward Draw)
由于G-Buffer没有保留所有历史层级的深度信息,半透明物体无法参与延迟光照。
标准的管线顺序是:
- Subpass 0: 绘制非透明物体,输出G-Buffer。
- Subpass 1: 读取G-Buffer,计算延迟光照。
- Subpass 2 (Or next RenderPass): 回退到前向渲染(Forward),绘制半透明物体。
注意:在绘制半透明物体时,如果需要读取不透明物体的色彩进行折射等效果,需确保Subpass 1的结果已经可以作为纹理被采样,这可能需要一次Resolve操作。
总结
在移动端TBDR架构下,设计高效的延迟渲染管线本质上是一场**“空间换带宽”**的游戏。
核心法则可以归纳为三点:
- 管线紧凑化:将G-Buffer的写入与光照计算锁死在同一个Render Pass的子通道(Subpass)内。
- 显存零分配:通过
DONT_CARE和LAZILY_ALLOCATED阻止G-Buffer数据流入VRAM。 - 数据极简化:严苛控制G-Buffer大小,防止Tile Memory溢出。
只要守住Tile Memory这条红线,移动端的延迟渲染不仅不会成为性能杀手,反而能在大规模动态光源场景下,展现出比传统前向渲染(Forward)更为出色的性能优势。