WEBKT

把G-Buffer留在Tile-Memory里:移动端TBDR延迟渲染管线优化实践

19 0 0 0

在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 PassSubpass(子通道) 机制完美契合了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 ShadersProgrammable 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没有保留所有历史层级的深度信息,半透明物体无法参与延迟光照。
标准的管线顺序是:

  1. Subpass 0: 绘制非透明物体,输出G-Buffer。
  2. Subpass 1: 读取G-Buffer,计算延迟光照。
  3. Subpass 2 (Or next RenderPass): 回退到前向渲染(Forward),绘制半透明物体。

注意:在绘制半透明物体时,如果需要读取不透明物体的色彩进行折射等效果,需确保Subpass 1的结果已经可以作为纹理被采样,这可能需要一次Resolve操作。


总结

在移动端TBDR架构下,设计高效的延迟渲染管线本质上是一场**“空间换带宽”**的游戏。

核心法则可以归纳为三点:

  1. 管线紧凑化:将G-Buffer的写入与光照计算锁死在同一个Render Pass的子通道(Subpass)内。
  2. 显存零分配:通过 DONT_CARELAZILY_ALLOCATED 阻止G-Buffer数据流入VRAM。
  3. 数据极简化:严苛控制G-Buffer大小,防止Tile Memory溢出。

只要守住Tile Memory这条红线,移动端的延迟渲染不仅不会成为性能杀手,反而能在大规模动态光源场景下,展现出比传统前向渲染(Forward)更为出色的性能优势。

GpuArchMind 移动端渲染TBDR架构延迟渲染

评论点评