每日编程实践: Deferred Shading Renderer 延迟渲染管线
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 | 着色所需信息: |
为什么需要世界坐标 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 | 对每个像素 (x, y): |
关键点:Geometry Pass 中不做任何光照计算,只收集几何信息。这就是”延迟”的含义——光照计算被推迟到下一阶段。
在 GPU 实现中,这一阶段通常用 MRT(Multiple Render Targets)同时输出多张纹理。在本软件实现中,我们直接写入内存数组:
1 | GBuffer geometryPass() { |
法线编码细节:G-Buffer 中存储的是原始法线向量([-1,1]³),不做压缩。在工业引擎中,法线通常被压缩存储(如 Oct-encoding 只需 2 个 float),节省显存带宽——这是 G-Buffer 设计中的重要优化点。
深度选择:本实现存储射线参数 t(线性深度),而非 NDC 深度(非线性)。NDC 深度在近平面附近精度高、远处精度低,对软渲染器不友好;线性深度计算距离衰减时更直接。
阶段2:Lighting Pass
Lighting Pass 是延迟渲染真正发力的地方:对每个屏幕像素,从 G-Buffer 读取几何信息,遍历所有光源计算光照贡献,求和输出最终颜色。
1 | 对每个像素 (x, y): |
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 更好?
物理上更准确:Phong 在掠射角(L 或 V 接近切线方向)时会产生高光截断——当
dot(R, V) < 0时高光突然消失,产生不自然的暗带。Blinn-Phong 的半角向量不会有这个问题。计算更高效:H 向量可以在光源位置变化时增量更新,对于方向光(远处光源)H 向量几乎不变。GPU 实现中,Blinn-Phong 的高光计算约比 Phong 快 10-15%。
与物理 BRDF 更接近:Cook-Torrance BRDF 中的 NDF(法线分布函数)也以
N·H为核心,Blinn-Phong 可以看作是 Cook-Torrance 的一个解析近似。
1 | // 漫反射(Lambert) |
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 | // 从着色点沿法线偏移,避免自遮挡 |
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 | Vec3 aces(Vec3 x) { |
这是一个有理函数(分子/分母都是二次多项式),具有以下性质:
- 暗部细节保留:低亮度区域几乎线性(斜率≈1)
- 高光柔和压缩:高亮度区域向白色渐近(不硬截断)
- 整体对比度:S 曲线形状,中间调对比度略有提升
与简单的 min(c, 1.0) 截断相比,ACES 避免了高光区域的”烧穿”(burn-out),使白色物体在强光下仍有层次感。
调试过程与坑
坑1:编译报错 assert 未声明
1 | // 错误原因:C++ 标准库的 assert 在 <cassert> 中 |
教训:C++ 中每个标准库函数/宏都有其对应的头文件,不能依赖隐式包含。即使某些编译器在某些平台下隐式包含了也能编译通过,这仍然是未定义行为。
坑2:convert 命令不可用(PPM→PNG 转换失败)
本地没有安装 ImageMagick,代码中调用 convert input.ppm output.png 失败。
修复:改用 Python3/PIL 做图像格式转换,这在任何有 Python 的系统上都可用:
1 | python3 -c " |
教训:外部工具依赖(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 可视化对比

左上(Albedo):材质的原始漫反射颜色,没有任何光照影响——这就是延迟渲染第一阶段的输出,纯几何信息。
右上(法线 RGB 编码):法线向量 (N+1)/2 映射到 [0,1],可以直观看到表面朝向:朝右=红,朝上=绿,朝外=蓝。球体顶部偏绿,侧面偏红/蓝。
左下(深度图):白色=近(t小),黑色=远(t大)。可以清晰看到球体的深度分层关系。
右下(延迟渲染结果):在第二阶段,完全基于上面三张 G-Buffer 计算出的光照结果。
G-Buffer 各通道(单独显示)
Albedo Buffer:
Normal Buffer:
Depth Buffer:
量化验证
1 | 中心金球 RGB: (227, 191, 71) → 金色,符合材质 ✅ |
延迟渲染的局限与工业扩展
固有局限
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 | g++ -O1 -std=c++17 deferred_shading.cpp -o deferred_shading |
完成时间: 2026-03-18 05:38(博客重写 2026-03-18)
代码行数: ~700 行 C++17
迭代次数: 5 次
编译器: g++ -std=c++17 -O1 -Wall -Wextra











