Deferred Shading Renderer 延迟渲染管线

背景:前向渲染的瓶颈

在图形渲染的发展历史上,前向渲染(Forward Rendering) 是最直观的做法——每个几何体对每个光源分别着色,最终叠加。这在光源数量少的情况下工作得很好,但当你想要一个现代游戏引擎级别的场景——数百个动态点光源、面积光、体积光——前向渲染的复杂度就变成了不可接受的瓶颈。

复杂度分析(前向 vs 延迟):

方案 每帧着色次数 典型场景 (1000球 × 100光)
前向渲染 O(geometry × lights) 100,000 次着色
延迟渲染 O(pixels × lights) 800×600 × 有效光 ≈ 按覆盖范围
Tiled Deferred O(pixels × 局部光源) 现代 AAA 标准方案

前向渲染还有另一个更隐蔽的问题:Overdraw。当多个物体重叠时,被遮挡的物体也会被着色,然后深度测试丢弃——计算量全部浪费。延迟渲染通过”先确定可见性,再着色”彻底解决了这个问题。

延迟渲染的代价是显存带宽(G-Buffer 需要存储多张全分辨率纹理)和对透明物体的限制,但在绝大多数不透明场景下,这个权衡是值得的。这也是为什么 Unreal Engine 4/5、Unity HDRP、Frostbite、CryEngine 都以延迟渲染为默认管线。


原理推导

G-Buffer 的本质

延迟渲染的核心洞察是:着色计算只需要着色点的局部信息,而不需要知道这个信息来自哪个几何体。

如果我们能把每个像素”应该看到什么”先存起来,光照计算就可以完全在屏幕空间进行,与场景几何体的数量无关。这个存储结构就是 G-Buffer(Geometry Buffer)

G-Buffer 是一组全屏纹理,每个像素存储着色所需的最小几何信息:

1
2
3
4
5
6
着色所需信息:
着色点位置 P → 计算光源方向和距离
表面法线 N → 计算漫反射和高光
漫反射颜色 albedo → 基础颜色
高光系数 specular → 镜面反射强度
粗糙度/光泽度 → 高光形状

为什么需要世界坐标 P?
你可能认为有深度值就够了——从 NDC 深度和屏幕坐标可以反推世界坐标。但实际上直接存储 posVS(视空间坐标)或 posWS(世界坐标)更方便,避免每帧都做矩阵求逆。这是典型的”空间换时间”,用 12 字节存 Vec3 换来了每帧省去的矩阵乘法。

G-Buffer 格式设计(本实现):

Buffer 数据 说明
Albedo Vec3 (RGB) 漫反射颜色,直接采样材质
Specular double 高光系数,控制镜面反射亮度
Normal Vec3 (归一化) 世界空间法线,[-1,1]³
Depth double 射线参数 t(线性深度)
Position Vec3 世界空间坐标

两阶段管线详解

阶段1:Geometry Pass

Geometry Pass 的任务是:遍历所有像素,确定每个像素对应的表面,写入 G-Buffer

1
2
3
4
5
对每个像素 (x, y):
1. 从相机发射射线
2. 与场景所有几何体求交(找最近的)
3. 如果命中:将几何信息写入 G-Buffer
4. 如果未命中:写入背景标记(depth = INF)

关键点:Geometry Pass 中不做任何光照计算,只收集几何信息。这就是”延迟”的含义——光照计算被推迟到下一阶段。

在 GPU 实现中,这一阶段通常用 MRT(Multiple Render Targets)同时输出多张纹理。在本软件实现中,我们直接写入内存数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
GBuffer geometryPass() {
GBuffer gbuf(width, height);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// NDC 坐标 [-1, 1]
double u = (2.0 * (x + 0.5) / width - 1.0) * aspectRatio * tanHalfFov;
double v = (1.0 - 2.0 * (y + 0.5) / height) * tanHalfFov;

Ray ray = {camera.origin, normalize(camera.forward + camera.right*u + camera.up*v)};
HitInfo hit = sceneIntersect(ray, scene);

int i = y * width + x;
if (hit.hit) {
gbuf.albedo[i] = hit.mat->albedo;
gbuf.specular[i] = hit.mat->specular;
gbuf.normal[i] = hit.normal; // 必须是归一化的世界法线
gbuf.depth[i] = hit.t;
gbuf.position[i] = ray.origin + ray.dir * hit.t;
}
// else: 默认值(depth=INF 表示背景)
}
}
return gbuf;
}

法线编码细节:G-Buffer 中存储的是原始法线向量([-1,1]³),不做压缩。在工业引擎中,法线通常被压缩存储(如 Oct-encoding 只需 2 个 float),节省显存带宽——这是 G-Buffer 设计中的重要优化点。

深度选择:本实现存储射线参数 t(线性深度),而非 NDC 深度(非线性)。NDC 深度在近平面附近精度高、远处精度低,对软渲染器不友好;线性深度计算距离衰减时更直接。


阶段2:Lighting Pass

Lighting Pass 是延迟渲染真正发力的地方:对每个屏幕像素,从 G-Buffer 读取几何信息,遍历所有光源计算光照贡献,求和输出最终颜色

1
2
3
4
5
6
7
8
9
10
11
12
对每个像素 (x, y):
1. 读取 G-Buffer → position, normal, albedo, specular
2. 如果 depth == INF:输出背景色,跳过
3. 计算视线方向 V = normalize(camera.origin - position)
4. 对每个光源 L:
a. 计算光源方向和距离
b. Shadow Ray 测试
c. 距离衰减
d. Blinn-Phong 漫反射 + 高光
e. 累加到像素颜色
5. 加环境光
6. ACES 色调映射 + Gamma 校正

Blinn-Phong BRDF 详解

本实现使用 Blinn-Phong 着色模型,而非原始 Phong。两者的区别值得深入理解。

Phong 模型(原始)

高光因子 = max(0, dot(R, V))^shininess

其中 R = reflect(-L, N) = 2*dot(N,L)*N - L 是反射方向。

Blinn-Phong 模型

高光因子 = max(0, dot(N, H))^shininess

其中 H = normalize(L + V)半角向量(Halfway Vector)

为什么 Blinn-Phong 更好?

  1. 物理上更准确:Phong 在掠射角(L 或 V 接近切线方向)时会产生高光截断——当 dot(R, V) < 0 时高光突然消失,产生不自然的暗带。Blinn-Phong 的半角向量不会有这个问题。

  2. 计算更高效:H 向量可以在光源位置变化时增量更新,对于方向光(远处光源)H 向量几乎不变。GPU 实现中,Blinn-Phong 的高光计算约比 Phong 快 10-15%。

  3. 与物理 BRDF 更接近:Cook-Torrance BRDF 中的 NDF(法线分布函数)也以 N·H 为核心,Blinn-Phong 可以看作是 Cook-Torrance 的一个解析近似。

1
2
3
4
5
6
7
8
9
10
11
// 漫反射(Lambert)
double NdotL = max(0.0, worldNorm.dot(lightDir));
Vec3 diffuse = albedo * light.color * NdotL * atten;

// 高光(Blinn-Phong)
Vec3 halfVec = normalize(lightDir + viewDir); // 半角向量
double NdotH = max(0.0, worldNorm.dot(halfVec));
double spec = pow(NdotH, shininess) * specularCoeff;
Vec3 specular = light.color * spec * atten;

color += diffuse + specular;

shininess 的物理含义:shininess 越大,高光越集中(物体越光滑)。Blinn-Phong 的 shininess 约为 Phong 的 4 倍才能产生相同的视觉效果(因为 N·H 的值通常比 R·V 更大,需要更高指数来收紧)。


距离衰减

光线在空间中的衰减遵循平方反比定律(Inverse Square Law)——光照强度与距离的平方成反比。但纯粹的 1/d²d→0 时趋向无穷大,不稳定。实际使用二次多项式近似:

1
attenuation = 1 / (Kc + Kl*d + Kq*d²)
  • Kc(常数项):防止近距离分母趋零
  • Kl(线性项):适合中等距离的衰减
  • Kq(二次项):控制远距离的快速衰减(物理正确项)

本实现参数:Kc=1.0, Kl=0.09, Kq=0.032——这是 Phong 光照模型文献中针对 50 单位范围光源的经验值(来自 LearnOpenGL)。

为什么不用纯 1/d²?
在软渲染器中,球心极近的像素(d≈0.001)会产生极大的 attenuation 值,导致 NaN 或亮度溢出。二次多项式的常数项 Kc=1 保证了分母的下界为 1。


硬阴影:Shadow Ray

实现阴影的方式是从着色点向光源发射 Shadow Ray,如果中间有遮挡,则跳过该光源的贡献:

1
2
3
4
5
6
7
// 从着色点沿法线偏移,避免自遮挡
Vec3 shadowOrigin = worldPos + worldNorm * SHADOW_BIAS; // SHADOW_BIAS = 0.001
Ray shadowRay(shadowOrigin, lightDir);

// 只检测 [0, dist-epsilon] 范围内的交点(不计入光源背后的物体)
HitInfo shadowHit = sceneIntersect(shadowRay, scene, 0.001, dist - 0.1);
if (shadowHit.hit) continue; // 在阴影中,跳过此光源

SHADOW_BIAS 的必要性

浮点精度问题——着色点本身在几何体表面,如果直接从该点发射 Shadow Ray,射线起点会和几何体表面几乎相交(t≈0),导致”自阴影”(acne):表面被自身挡住,产生随机黑色斑点。

沿法线方向偏移一个小量(0.001)把起点推离表面,避免 t≈0 的假命中。

偏移量的选择:太小仍然有 acne,太大会在接触处产生漏光(光线穿过物体间的缝隙)。0.001 是在场景尺度 ~10 下的经验值。在实际引擎中,SHADOW_BIAS 会根据斜面角度自适应调整——法线越接近掠射角,需要越大的偏移。


ACES 色调映射

真实光照计算产生的 HDR 颜色值(High Dynamic Range)会超过 [0,1] 范围,需要映射到显示器支持的 LDR 范围。ACES(Academy Color Encoding System) 是目前最广泛使用的色调映射曲线之一,Unreal Engine 4 默认使用。

ACES 近似公式(Hill 2017):

1
2
3
4
5
6
7
Vec3 aces(Vec3 x) {
const double a=2.51, b=0.03, c=2.43, d=0.59, e=0.14;
auto f = [&](double v) {
return clamp((v*(v*a+b)) / (v*(v*c+d)+e), 0.0, 1.0);
};
return {f(x.x), f(x.y), f(x.z)};
}

这是一个有理函数(分子/分母都是二次多项式),具有以下性质:

  • 暗部细节保留:低亮度区域几乎线性(斜率≈1)
  • 高光柔和压缩:高亮度区域向白色渐近(不硬截断)
  • 整体对比度:S 曲线形状,中间调对比度略有提升

与简单的 min(c, 1.0) 截断相比,ACES 避免了高光区域的”烧穿”(burn-out),使白色物体在强光下仍有层次感。


调试过程与坑

坑1:编译报错 assert 未声明

1
2
3
// 错误原因:C++ 标准库的 assert 在 <cassert> 中
// 不像 C 那样可以隐式使用
#include <cassert> // ← 必须显式包含

教训:C++ 中每个标准库函数/宏都有其对应的头文件,不能依赖隐式包含。即使某些编译器在某些平台下隐式包含了也能编译通过,这仍然是未定义行为。

坑2:convert 命令不可用(PPM→PNG 转换失败)

本地没有安装 ImageMagick,代码中调用 convert input.ppm output.png 失败。

修复:改用 Python3/PIL 做图像格式转换,这在任何有 Python 的系统上都可用:

1
2
3
4
5
python3 -c "
from PIL import Image
img = Image.open('input.ppm')
img.save('output.png')
"

教训:外部工具依赖(ImageMagick、ffmpeg 等)应该作为可选 fallback,代码中优先使用系统标准库或 Python 等普遍可用的工具。

坑3:Shadow Ray 自遮挡产生随机黑斑

初始版本忘记加 SHADOW_BIAS,球体表面产生大量随机黑色像素(acne)。

根因:浮点精度导致 Shadow Ray 起点和球体表面几乎相交(t=0.000001),被错误判断为有遮挡物。

修复shadowOrigin = worldPos + worldNorm * 0.001

验证方法:去掉所有光源只保留一个,检查球面是否有均匀的明暗分布,随机黑斑是阴影 bias 不足的典型特征。

坑4:未使用变量警告干扰输出

-Wall -Wextra 下,几个仅在 debug 模式下使用的变量产生 warning,用 (void)var_name 消除。


运行结果

最终延迟渲染输出

最终渲染

8 个彩色球体在 8 个不同颜色点光源的照射下,展现出彩色光照的混合效果。金色中心球受顶部白光和橙色侧光影响,呈现暖调高光;蓝色和绿色球在冷色背景光下轮廓分明。

G-Buffer 可视化对比

G-Buffer 对比

左上(Albedo):材质的原始漫反射颜色,没有任何光照影响——这就是延迟渲染第一阶段的输出,纯几何信息。

右上(法线 RGB 编码):法线向量 (N+1)/2 映射到 [0,1],可以直观看到表面朝向:朝右=红,朝上=绿,朝外=蓝。球体顶部偏绿,侧面偏红/蓝。

左下(深度图):白色=近(t小),黑色=远(t大)。可以清晰看到球体的深度分层关系。

右下(延迟渲染结果):在第二阶段,完全基于上面三张 G-Buffer 计算出的光照结果。

G-Buffer 各通道(单独显示)

Albedo Buffer
Albedo

Normal Buffer
Normals

Depth Buffer
Depth


量化验证

1
2
3
4
5
6
中心金球 RGB:        (227, 191, 71)  → 金色,符合材质 ✅
亮像素占比: 72% → 场景大部分被照亮 ✅
平均亮度: 0.486 → 中等亮度,无过曝 ✅
G-Buffer 几何覆盖: 87% → 87% 像素命中几何体 ✅
背景像素占比: 13% → 球体间隙和背景 ✅
运行时间: 0.8 秒 → 800×600,8灯,8球体,单线程 CPU ✅

延迟渲染的局限与工业扩展

固有局限

1. 透明物体无法直接支持

G-Buffer 每个像素只能存储一层几何信息(最近的表面),透明物体需要多层混合,与 G-Buffer 的单层设计冲突。

工业解决方案:

  • 不透明物体走延迟管线(G-Buffer)
  • 透明物体走前向管线,最后合成到延迟结果上
  • 这就是 UE4 的 Deferred + Forward 混合渲染策略

2. MSAA 抗锯齿实现复杂

MSAA 需要在 subpixel 级别保存多个采样点的深度/颜色,与 G-Buffer 的按像素设计不兼容。延迟渲染通常改用 TAA(时域抗锯齿)或 FXAA(后处理抗锯齿)代替 MSAA。

3. G-Buffer 显存带宽压力

一张 1920×1080 的完整 G-Buffer(position 16B + normal 16B + albedo 4B + roughness/metallic 4B)约需 85MB,每帧读写一次就是 85MB 的带宽消耗。现代 GPU 通过 DCC(Delta Color Compression)和 HTILE(Hierarchical Tile)来缓解这个问题。

工业级优化方向

优化 原理 收益
Tiled Deferred 把屏幕分成 16×16 的 tile,每个 tile 只处理覆盖它的光源 多光源场景减少 80%+ 光照计算
Clustered Deferred 在视锥体的 3D 空间中分 cluster,进一步减少光源遍历 比 Tiled 更适合大场景深度变化大的情况
G-Buffer 压缩 Oct-encoding 法线(2 float→2 bytes),RGBE 颜色格式 减少 50% 显存带宽
Early Z / Depth Pre-Pass 几何阶段先做一遍纯深度渲染,Lighting Pass 用 Equal 深度测试剔除 overdraw 对复杂场景减少无效着色
Light Culling GPU Compute 阶段按 tile/cluster 剔除不影响该区域的光源 UE5 Lumen 的核心之一

UE5 的 Lumen 全局光照在延迟渲染基础上增加了:

  • Surface Cache:缓存场景表面的辐射度,支持低频 GI
  • Screen Space Probe:在屏幕空间放置探针,追踪近场光线
  • 硬件光追 + 软件光追混合:近处用 Hardware RT,远处用 SDF Ray Marching

与本系列其他技术的对比

技术 输入 输出 关键缓冲区
SSAO(03-13) G-Buffer(法线+深度) AO 遮蔽图 Depth/Normal Buffer
SSR(03-16) G-Buffer + 已着色图 反射颜色图 Depth/Color Buffer
延迟渲染(03-18) 几何信息 G-Buffer → 着色图 Albedo/Normal/Depth/Pos

SSAO 和 SSR 都是在 G-Buffer 上工作的屏幕空间技术——这意味着它们天然地和延迟渲染管线兼容:Geometry Pass 填充的 G-Buffer 可以同时被 SSAO、SSR、延迟光照三个 Pass 复用,这正是延迟管线在工业实践中如此强大的根本原因。


代码规模与工程总结

  • 代码行数:~700 行 C++17
  • 运行时间:0.8 秒(800×600,8 点光源,8 球体,单线程 CPU)
  • 输出文件:5 张 PNG(最终输出、G-Buffer×3、对比图)
  • 依赖:仅标准库 + stb_image_write(PNG 写入)

本实现有意保持最简——没有 BVH 加速、没有多线程、没有纹理采样,目的是让管线本身的逻辑清晰可读。每一个 for 循环都对应一个真实 GPU Pass,每一个数组写入都对应一次 G-Buffer 写入。

这个”玩具渲染器”和 UE5 的延迟渲染器在结构上是同构的,区别只在于工程化程度:UE5 的 Geometry Pass 是用 MRT 同时写 7 张纹理,Lighting Pass 用 Compute Shader 做 Tiled Deferred;而这里的 Geometry Pass 是一个双层 for 循环写 5 个数组。本质上是同一件事。


代码仓库

GitHub: Deferred Shading Renderer

1
2
3
4
g++ -O1 -std=c++17 deferred_shading.cpp -o deferred_shading
./deferred_shading
# 输出:deferred_output.png, gbuffer_albedo.png,
# gbuffer_normals.png, gbuffer_depth.png, deferred_comparison.png

完成时间: 2026-03-18 05:38(博客重写 2026-03-18)
代码行数: ~700 行 C++17
迭代次数: 5 次
编译器: g++ -std=c++17 -O1 -Wall -Wextra