深入解析延迟渲染(Deferred Shading):原理、Vulkan Subpass 实践与进阶优化

深入解析延迟渲染(Deferred Shading)

延迟渲染(Deferred Shading)是现代图形渲染引擎(如 Unreal Engine、Unity HDRP 及自定义 Vulkan/DirectX12 引擎)中处理多光源场景的核心渲染范式。本文将从基本原理G-Buffer 设计Vulkan Subpass 工程实践混合渲染工作流进阶光照优化进行全面解构与扩展。


一、 渲染范式变革:从正向到延迟

1. 正向渲染(Forward Rendering)的痛点

在传统的正向渲染管线中,光照计算直接在几何体的片元着色器(Fragment Shader)阶段执行。渲染复杂度通常表示为: \[\text{Complexity} = O(N_{\text{objects}} \times M_{\text{lights}} \times \text{Overdraw})\]

  • 光照计算冗余 (Overdraw Overhead):即便启用了 Early-Z(早期深度测试),在存在复杂遮挡、Alpha 测试或无序绘制的场景中,许多片元在执行完极耗性能的光照算法后,仍可能在后续测试中被丢弃,造成大量的算力浪费。
  • 多光源性能骤降:每增加一个光源,每个受影响的几何体都需要重新绘制一次(或在单个 Shader 内用循环遍历所有光源),导致着色器指令膨胀、变体激增,性能随光源数量呈线性甚至指数级下降。

2. 延迟渲染的核心解耦思想

延迟渲染的核心在于“将几何处理与光照计算彻底解耦”。整个渲染过程分为两个主阶段:

  1. 几何阶段(Geometry Pass):渲染场景几何体,但不计算任何光照,仅将光照计算所需的表面属性(位置、法线、材质参数等)写入一组称为 G-Buffer(G-缓冲区) 的帧缓冲附件中。
  2. 光照阶段(Lighting Pass):以全屏矩形(Screen Quad)或光源体积为驱动,逐像素读取 G-Buffer 中的信息,统一执行光照计算并输出最终颜色。

解耦后的计算复杂度降为: \[\text{Complexity} = O(N_{\text{objects}}) + O(\text{Screen Pixels} \times M_{\text{visible\_lights}})\]

关键优势:在此流程下,光照计算仅对最终在屏幕上可见的像素执行一次,彻底消除了 Lighting 阶段的 Overdraw 浪费。


二、 G-Buffer 架构设计与存储优化

G-Buffer(Geometry Buffer)是连接几何阶段与光照阶段的数据纽带。合理的 G-Buffer 布局不仅能节约显存空间,还能大幅降低带宽占用。

1. 经典 G-Buffer 布局

以计算 Blinn-Phong 或基础 PBR 光照为例,标准 G-Buffer 包含以下物理量:

附件名称 记录数据 推荐格式 占用内存/像素
G-Position 观察空间/世界空间位置 \((X, Y, Z)\) VK_FORMAT_R16G16B16A16_SFLOAT 8 Bytes
G-Normal 表面法线向量 \((N_x, N_y, N_z)\) VK_FORMAT_R16G16B16A16_SFLOAT 8 Bytes
G-AlbedoSpec 漫反射颜色 \((R, G, B)\) + 镜面强度/粗糙度 VK_FORMAT_R8G8B8A8_UNORM 4 Bytes
G-Depth 硬件深度缓冲区 (Depth Buffer) VK_FORMAT_D32_SFLOAT 4 Bytes

2. 工业级 G-Buffer 带宽优化扩展

在实际工程中,8 字节/像素的 G-Position 会带来巨大的显存带宽开销。现代引擎通常采用以下优化手段:

  1. 深度重建位置 (Position Reconstruction from Depth)
    • 原理:取消独立的 G-Position 附件。在光照阶段,利用 G-Depth 中的深度值 \(d\),结合像素的屏幕坐标 \((u, v)\) 和相机的逆投影矩阵(Inverse Projection Matrix),实时反算出视空间或世界空间坐标。
    • 收益:直接将 G-Buffer 的写出带宽降低约 30%~40%
  2. 法线编码压缩 (Normal Encoding)
    • 利用八面体编码(Octahedral Normal Encoding)将单位法线向量 \((N_x, N_y, N_z)\) 压缩存储至 2 个 8-bit 或 16-bit 通道,进一步节省显存。

三、 延迟渲染的权衡利弊

维度 延迟渲染(Deferred Shading) 正向渲染(Forward Rendering)
多光源扩展性 极其优秀,可轻松支持上千动态光源 较差,受限于 Shader 遍历或多 Pass 绘制
Overdraw 消耗 光照计算无 Overdraw 光照计算存在严重 Overdraw
半透明渲染 原生不支持(只能存储单层片元) 原生支持(从后往前排序混合)
显存与带宽 极高(G-Buffer 频繁读写) 极低(直接写入 Render Target)
抗锯齿 (AA) 无法原生使用硬件 MSAA 原生完美支持硬件 MSAA
材质多样性 受限(所有物体倾向共享同一种 Shader) 灵活(每个物体可独立编写复杂 Shader)

局限性的主流解决方案

  • 半透明物体:使用混合渲染流,即先用延迟渲染处理不透明物体,再用正向渲染 Pass 叠加透明物体。
  • 硬件 MSAA 缺失:使用后处理抗锯齿技术(如 TAASMAAFXAA)。
  • 材质单一:在 G-Buffer 中增加 Material ID 通道,光照阶段根据 ID 进行着色分支处理(Branching)。

四、 工程实现:基于 Vulkan Subpass 的现代管线实践

在移动端 Tile-Based GPU(TBR/TBDR)及现代 PC 显卡架构下,传统的 Multi-Pass 延迟渲染(将 G-Buffer 写入 VRAM,再从 VRAM 读取)会导致极高的显存带宽消耗与发热。

Vulkan 引入了 SubpassInput Attachment 机制,允许 G-Buffer 数据保存在 Tile Memory(片上 SRAM 高速缓存) 中,甚至不需要写回主显存,从而实现极其高效的延迟光照。

1
2
3
4
5
6
7
8
9
+-------------------------------------------------------------------+
| VkRenderPass |
| +---------------------------+ +---------------------------+ |
| | Subpass 0 (Geometry) | | Subpass 1 (Lighting) | |
| | | | | |
| | Writes to G-Buffer |====>| Reads via subpassInput | |
| | Attachments | | (Kept in Tile Memory!) | |
| +---------------------------+ +---------------------------+ |
+-------------------------------------------------------------------+

1. Subpass 0:几何阶段 (Geometry Pass)

几何 Pass 的使命是将场景物体的表面属性渲染并填入 G-Buffer 附件中。

GLSL 几何阶段着色器示例 (Geometry Shader)

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
26
#version 450

// 对应 Vulkan VkSubpassDescription 中 pColorAttachments 的索引
layout (location = 0) out vec4 gPosition;
layout (location = 1) out vec4 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;

layout (location = 0) in vec2 inTexCoords;
layout (location = 1) in vec3 inFragPos;
layout (location = 2) in vec3 inNormal;

layout (set = 0, binding = 0) uniform sampler2D textureDiffuse;
layout (set = 0, binding = 1) uniform sampler2D textureSpecular;

void main()
{
// 1. 存储片段世界/视空间位置向量
gPosition = vec4(inFragPos, 1.0);

// 2. 存储归一化法线向量
gNormal = vec4(normalize(inNormal), 1.0);

// 3. 存储漫反射 RGB,并将镜面反射强度 (Specular) 存入 Alpha 通道
gAlbedoSpec.rgb = texture(textureDiffuse, inTexCoords).rgb;
gAlbedoSpec.a = texture(textureSpecular, inTexCoords).r;
}

2. Subpass 1:延迟光照阶段 (Lighting Pass)

在第二个 Subpass 中,G-Buffer 被配置为 Input Attachments。着色器通过 subpassInput 内置类型和 subpassLoad() 函数直接读取当前像素在片上缓存中的数据。

依赖同步 (VkSubpassDependency) 配置

为了确保几何阶段写操作完成后再进行光照读取,必须配置 Subpass 依赖:

1
2
3
4
5
6
7
8
VkSubpassDependency dependency = {};
dependency.srcSubpass = 0; // 几何 Subpass
dependency.dstSubpass = 1; // 光照 Subpass
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
dependency.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
dependency.dstAccessMask = VK_ACCESS_INPUT_ATTACHMENT_READ_BIT;
dependency.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT; // 保证像素级按 Region 同步

GLSL 光照阶段着色器示例 (Lighting Shader)

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#version 450

layout (location = 0) out vec4 FragColor;
layout (location = 0) in vec2 inTexCoords;

// 在 Vulkan 中使用 subpassInput 直接从片上 Tile 内存中读取像素数据
layout (input_attachment_index = 0, set = 0, binding = 0) uniform subpassInput inPosition;
layout (input_attachment_index = 1, set = 0, binding = 1) uniform subpassInput inNormal;
layout (input_attachment_index = 2, set = 0, binding = 2) uniform subpassInput inAlbedoSpec;

struct PointLight {
vec3 position;
vec3 color;
float intensity;
};

const int MAX_LIGHTS = 32;
layout (set = 1, binding = 0) uniform LightBuffer {
PointLight lights[MAX_LIGHTS];
vec3 viewPos;
int lightCount;
} ubo;

void main()
{
// 使用 subpassLoad 读取当前像素位置对应的 G-Buffer 属性
vec3 fragPos = subpassLoad(inPosition).rgb;
vec3 normal = subpassLoad(inNormal).rgb;
vec3 albedo = subpassLoad(inAlbedoSpec).rgb;
float specStr = subpassLoad(inAlbedoSpec).a;

// 环境光计算
vec3 lighting = albedo * 0.1;
vec3 viewDir = normalize(ubo.viewPos - fragPos);

// 累加所有光源的光照影响 (Blinn-Phong 算法)
for(int i = 0; i < ubo.lightCount; ++i)
{
vec3 lightDir = normalize(ubo.lights[i].position - fragPos);

// 漫反射 (Diffuse)
float diff = max(dot(normal, lightDir), 0.0);
vec3 diffuse = diff * albedo * ubo.lights[i].color;

// 高光 (Specular - Blinn-Phong)
vec3 halfWayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfWayDir), 0.0), 32.0);
vec3 specular = ubo.lights[i].color * spec * specStr;

lighting += (diffuse + specular) * ubo.lights[i].intensity;
}

FragColor = vec4(lighting, 1.0);
}

五、 混合渲染工作流:处理半透明与调试几何体

延迟渲染无法直接渲染半透明物体(如玻璃、烟雾)或需要特殊着色器的小控件(如光源代理 3D 立方体)。通常的解决方案是将管线分为延迟阶段正向阶段

深度复用与状态配置 (Depth Buffer Reuse)

传统 OpenGL 做法可能需要调用 glBlitFramebuffer 拷贝深度缓冲,但在 Vulkan 中,可通过合理的 RenderPass 规划避免拷贝性能损失:

  1. 共享深度附件:在随后执行的正向渲染 Pass 中,直接绑定几何 Pass 所填充的 VkImageView 深度附件。
  2. 深度加载指令:将该深度附件的加载操作设置为 VK_ATTACHMENT_LOAD_OP_LOAD,这会完整保留几何阶段写入的场景深度信息。
  3. 管线测试状态:在正向渲染管线中开启深度测试、关闭深度写入:
    1
    2
    3
    4
    VkPipelineDepthStencilStateCreateInfo depthStencil = {};
    depthStencil.depthTestEnable = VK_TRUE; // 开启深度测试(保证正确遮挡关系)
    depthStencil.depthWriteEnable = VK_FALSE; // 关闭深度写入(避免半透明物体遮挡后续绘制)
    depthStencil.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;

六、 进阶光照优化策略

全屏绘制光照阶段(Screen-space Quad Lighting)会导致场景中的每一个像素都遍历计算所有光源,导致 \(O(\text{Pixels} \times \text{Lights})\) 的复杂度。在成千上万个动态光源的场景下,性能依然会遭遇瓶颈。

1. 光体积截断 (Light Volumes & Attenuation Bounds)

由于点光源的照射强度随距离衰减,距离光源较远的片元受到的光照影响微乎其微。我们可以通过数学公式推导出一个极限受光半径 \(R_{\text{light}}\),并仅在该半径封装的几何体积(如简易球体或网格)覆盖的屏幕区域计算光照。

点光源衰减公式与半径推导

常用的点光源衰减方程如下:

\[ F_{\text{attenuation}} = \frac{1}{K_c + K_l d + K_q d^2} \]

其中: * \(d\) 为片元到光源的距离。 * \(K_c\) 为常数项(Constant),\(K_l\) 为一次线性项(Linear),\(K_q\) 为二次项(Quadratic)。

假设定义当光照贡献低于极限色彩阈值 \(I_{\text{threshold}}\)(例如在 8-bit 帧缓冲下取值 \(I_{\text{threshold}} = \frac{5}{256} \approx 0.0195\))时,光照视作不可见。令强度边界方程成立:

\[ I_{\text{max}} \cdot \frac{1}{K_c + K_l d + K_q d^2} = I_{\text{threshold}} \]

将其整理为关于距离 \(d\) 的标准一元二次方程:

\[ K_q d^2 + K_l d + \left(K_c - \frac{I_{\text{max}}}{I_{\text{threshold}}}\right) = 0 \]

根据一元二次方程求根公式,解得光源的最大影响半径 \(R_{\text{light}}\) 为:

\[ R_{\text{light}} = \frac{-K_l + \sqrt{K_l^2 - 4 K_q \left(K_c - \frac{I_{\text{max}}}{I_{\text{threshold}}}\right)}}{2 K_q} \]

实施方式

在光照 Pass 中,不再绘制全屏矩形,而是为每个点光源绘制一个半径为 \(R_{\text{light}}\) 的低模球体。球体覆盖的像素即为受光影响的像素,大幅缩减了无效片段的 Lighting Shader 执行次数。


2. 现代架构演进:Tiled & Clustered Deferred Shading

随着场景光源数量进一步增加(如上万个光源),频繁提交光照几何体(Light Volume Proxy)会导致 Draw Call 和几何顶点过高。现代 3D 引擎采用了更高效的空间分块策略:

1
2
3
4
5
6
7
8
+------------------------+---------------------------------------+
| 渲染技术 (Technology) | 空间划分维度 |
+------------------------+---------------------------------------+
| Traditional Deferred | 全屏幕逐像素处理 |
| Light Volumes | 3D 几何代理 (Proxy Sphere/Cone) Mesh |
| Tiled Deferred | 2D 屏幕分块 (如 16x16 像素 Tile) |
| Clustered Deferred | 3D 空间切块 (2D Tile + Z-Depth 视锥切片)|
+------------------------+---------------------------------------+
  1. 分块延迟渲染 (Tiled Deferred Shading)
    • 将屏幕切割为 \(16 \times 16\) 像素的小块(Tiles)。
    • 使用 Compute Shader 在视锥体空间内求交,计算每个 Tile 覆盖的光源列表,将光源索引存入 Buffer。
    • 光照 Pass 逐像素读取当前 Tile 的光源列表,避免全屏光源遍历。
  2. 集群延迟渲染 (Clustered Deferred / Forward+)
    • 在 Tiled 的基础上的,将 View Frustum 在 Z 轴深度方向上进一步切片,形成 3D 空间栅格(Clusters/Voxels)。
    • 彻底解决了 2D Tile 在深度跨度较大时(例如景深极深处与近处物体重叠在同一个 Tile)导致的光源误判与计算浪费。同时,这种集群裁剪思想还能无缝扩展至 Forward+ 管线,实现透明物体与不透明物体的统一光照管理。

七、 总结

延迟渲染通过“空间换时间”“计算解耦”的思想,为现代 3D 引擎提供了强大的多光源渲染能力。在工程实践中: * 理解 G-Buffer 布局设计是降低渲染带宽的关键; * 结合 Vulkan Subpass 与 Tile Memory 是移动端与现代图形 API 发挥硬件极致性能的必然选择; * 配合 深度重建位置光体积裁剪Clustered 方案,使得千万级光照渲染在实时应用中成为可能。

本文作者:Berg Zha

本文链接:http://junglemanpro.com/2026/06/06/deferred_shading/

版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

ESC 关闭 | 导航 | Enter 打开
输入关键词开始搜索