传统前向渲染逐物体计算光照,灯光一多 CPU 端提交与像素端重复着色同时爆炸。延迟渲染把"先画几何、再算光照"拆成两阶段:几何阶段把法线、反照率、金属度等材质属性写入 G-Buffer,光照阶段逐像素从 G-Buffer 取属性、只对影响该像素的光源做 BRDF 计算——光源数量从此与场景几何解耦。再叠加 Tiled/Clustered 光源剔除,成百上千盏动态灯也能实时跑。本文从 G-Buffer 布局讲到移动端带宽优化,给出完整的延迟渲染知识地图,与 渲染管线理论 一脉相承。
一、G-Buffer 布局与延迟渲染管线
一句话:延迟渲染的核心是把"材质属性"从着色阶段抽离到一张张中间纹理(G-Buffer)里,光照阶段只做读与算,不再重跑顶点与像素着色器。
1.1 两阶段架构
延迟渲染(Deferred Rendering)分两个 Pass:
- 几何 Pass(Geometry Pass):渲染所有几何体,不计算光照,只把每个像素的材质属性写入多张渲染目标(MRT),即 G-Buffer。
- 光照 Pass(Lighting Pass):对全屏三角形逐像素采样 G-Buffer,恢复世界坐标/法线等,然后对该像素施加以屏幕为单位的若干光源着色。
几何 Pass:
顶点 → 像素着色器 → MRT 同时写入 [albedo, normal, metal/rough, depth]
光照 Pass:
全屏三角形 → 逐像素重建 P、N → 遍历光源列表 → 累加 BRDF → 输出 HDR 颜色
1.2 常见 G-Buffer 布局
| 目标 | 通道 | 格式 | 存储内容 |
|---|---|---|---|
| GBuffer0 | RGBA8 | 反照率 albedo(RGB)+ 遮挡 AO(A) | 3+1 |
| GBuffer1 | RGBA16F | 法线(RGB,线性)+ 粗糙度(A) | 3+1 |
| GBuffer2 | RGBA8 | 金属度(R)+ 自发光(G)+ 置换/次表面标志(BA) | 1+1+2 |
| Depth | D32F | 深度(用于重建世界坐标) | 1 |
一个经典 DX11 布局用三张 RGBA8 加一张深度就够,而带视差/次表面/材质 ID 的 AAA 管线常扩到 5~8 张。法线用 16F 或两通道八面体(Octahedral)编码可省一半带宽。
1.3 世界坐标重建
光照 Pass 不存世界坐标(太贵),而是从深度重建:
float d = tex2D(depthTex, uv).r; // [0,1] 深度
vec3 ndc = vec3(uv * 2.0 - 1.0, d * 2.0 - 1.0);
vec4 viewPos = invProj * vec4(ndc, 1.0); // 视图空间
viewPos.xyz /= viewPos.w;
vec3 worldPos = invView * viewPos.xyz; // 世界空间
更常见的是"线性化深度 + 相机视锥射线"方案:把远平面四个角的世界坐标插值成射线,乘线性深度即得世界坐标,只需一张深度纹理。
// GLSL:光照 Pass 核心片段
vec4 g0 = texture(gAlbedo, uv);
vec4 g1 = texture(gNormal, uv);
vec4 g2 = texture(gMeta, uv);
vec3 albedo = g0.rgb;
float ao = g0.a;
vec3 normal = normalize(g1.xyz * 2.0 - 1.0);
float rough = g1.a;
float metal = g2.r;
vec3 worldPos = reconstructWorld(uv, depthTex);
vec3 color = vec3(0.0);
for (int i = 0; i < lightCount; ++i) {
Light l = lights[i];
color += evaluateBRDF(albedo, metal, rough, normal,
worldPos, l); // 逐光源着色
}
fragColor = vec4(color * ao, 1.0);
二、前向渲染 vs 延迟渲染 vs 前向+
一句话:前向逐物体处理光照、光源多了几何与像素双爆炸;延迟把光照与几何解耦但吃带宽且难抗锯齿;前向+ 用 Tile 分类光源、两种管线优势通吃。
2.1 三种管线特性对比
| 特性 | 前向 Forward | 延迟 Deferred | 前向+ Forward+ |
|---|---|---|---|
| 光照与几何 | 耦合(逐物体) | 解耦(逐像素) | 解耦(Tile 分类) |
| 光源上限 | ~几十盏 | 上千盏 | 上千盏 |
| 材质复杂度 | 每个着色器一份 | G-Buffer 决定上限 | 与材质数量正相关 |
| MSAA | 原生支持 | 难做(见第七章) | 原生支持 |
| 带宽 | 低 | 高(多张 MRT) | 中低 |
| 带宽/填充率瓶颈 | 填充率 | 带宽 | 带宽(较缓) |
2.2 前向的困境
前向渲染里,一个像素会被多个光源重复着色:若一个像素受 100 盏灯影响,片段着色器就要跑 100 次 BRDF 计算。CPU 端还要按光源裁剪几何、为每种"光源类型 × 材质"组合切换着色器,Draw Call 与状态切换随灯光数量线性增长。所以传统前向引擎最多撑几十盏动态灯。
2.3 前向+(Forward+)与 Tiled Forward
前向+ 是延迟思路的"轻量版":几何照常走前向着色,但光照 Pass 前先做一个 Tile 分类——把屏幕切分成 16×16 块,为每块预计算影响它的光源列表。片段着色器只需遍历自己所在 Tile 的灯光:
逐 Tile(计算着色器):
for t in tiles:
for l in lights: if 光源视锥与 tile 视锥相交 → 加入 t 的光源表
逐像素(前向):
取本像素所在 tile 的光源表 → 只对这些灯做 BRDF
前向+ 保留了前向的 MSAA 与半透明便利,又能渲染大量光源,是移动端(Tile-Based GPU)最受欢迎的组合。
三、Tiled 光照剔除
一句话:Tiled Deferred 把屏幕切成 2D 网格,对每块做光源视锥剔除,把"逐像素遍历所有灯"降为"逐像素遍历本块少数灯",光照复杂度从 O(N×M) 变 O(N×k)。
3.1 分块与光源表
把屏幕划分成 16×16(或 8×8)像素的 Tile,每个 Tile 对应一个视锥(由该块的深度范围与四角射线构成)。用计算着色器遍历所有光源,对每个光源求其与哪些 Tile 相交,写入紧凑的 光源索引表:
tile0: [3, 7, 9]
tile1: [3, 9, 12]
tile2: []
光照 Pass 中,像素 (x,y) 定位到 Tile,只读取该 Tile 的光源索引数组:
// HLSL:Tiled 光源列表读取
uint tileX = (px + 0.5) * rcpTileSize;
uint tileY = (py + 0.5) * rcpTileSize;
uint tileId = tileY * tilesX + tileX;
uint start = tileLightCounts[tileId];
uint count = tileLightIndices[tileId + tilesX * tilesY]; // 常见布局:counts 后接索引
for (uint i = start; i < start + count; ++i) {
Light l = lights[lightList[tileId * MAX + i]];
color += shade(l, gBufferData);
}
3.2 剔除测试
Tile 视锥剔除一个光源:把光源包围球投影到屏幕,看球投影圆与 Tile 矩形是否相交;再把光源深度范围与 Tile 的 [minDepth, maxDepth] 比较,做深度剔除。两步都过才加入列表。
- 点光源/聚光灯用包围球 vs 视锥平面(偏平截锥)。
- 方向光(平行光)不进列表,单独作为全局项在光照 Pass 中统一加。
3.3 复杂度收益
设屏幕共 T 个 Tile、光源 L 个、平均每 Tile k 盏灯。剔除阶段成本 O(T×L)(可并行),光照阶段从 O(P×L)(P=像素数)降到 O(P×k)。当 k ≈ 1~16 时,千盏灯也仅等效于"每像素十几盏灯",这是 Tiled 的巨大价值。
四、Clustered 光照剔除
一句话:Tiled 只在屏幕平面分块,垂直方向用整个深度范围,导致前后景共享同一份光源表;Clustered 把视锥体在深度方向再切几层,形成 3D 网格,剔除更精准。
4.1 从 2D Tile 到 3D Cluster
Tiled 的局限:一个 Tile 覆盖从近平面到远平面的整条射线,一盏远处小灯会让整条射线上所有像素都遍历它。Clustered(也称 3D Tiled)把视锥体沿深度切成 n 层,与屏幕的 m×m 网格组成 m×m×n 个 Cluster 格子(典型 8×8×32 = 2048 格):
深度切层(对数分布,近处密远处疏):
z_i = near * (far/near)^(i/n) // 对数切分,近处更精确
光源与 Cluster 的求交用"光源球体与格子包围盒"的快速测试,比 Tiled 的视锥测试更紧、更好并行。
4.2 实作:光源列表构建
// HLSL:Cluster 剔除(计算着色器,每光源一个线程组)
RWStructuredBuffer<uint> lightCounts; // 每 cluster 一个计数器
RWStructuredBuffer<uint> lightList; // 紧凑光源索引
[numthreads(128, 1, 1)]
void CS(uint3 id : SV_DispatchThreadID) {
Light l = lights[id.x];
// 用光源 AABB 与 cluster 网格求交,得到格子区间
uint3 minC, maxC;
lightToClusters(l, minC, maxC);
for (uint z = minC.z; z <= maxC.z; ++z)
for (uint y = minC.y; y <= maxC.y; ++y)
for (uint x = minC.x; x <= maxC.x; ++x) {
uint ci = packCluster(x, y, z);
uint slot;
InterlockedAdd(lightCounts[ci], 1, slot); // 原子分配
if (slot < MAX_PER_CLUSTER)
lightList[ci * MAX_PER_CLUSTER + slot] = id.x;
}
}
光照 Pass 里,先由像素深度定位 z 层,再取该 Cluster 的光源表。
4.3 Clustered vs Tiled
| 维度 | Tiled | Clustered |
|---|---|---|
| 网格 | 2D(屏幕) | 3D(+ 深度分层) |
| 深度剔除 | 整个 Tile 一个 min/max | 每层单独,更精准 |
| 光源数 | 更多冗余 | 更紧凑 |
| 开销 | 剔除快 | 剔除略重 |
| 适用 | 光源分布均匀 | 光源纵深差异大的场景 |
Clustered 也能承载非光源的"光体积"数据:雾、体积光、反射探针、探针等都能放进同一张表,扩展成通用的 Cluster-based Visibility。
五、剔除实现细节:数据结构与计算着色器
一句话:Tiled/Clustered 的性能关键在于光源数据的紧凑布局、原子分配与稳定的浅层循环——把剔除当作一次数据整理,而非着色逻辑的一部分。
5.1 光源数据结构
// HLSL:紧凑光源描述(StructuredBuffer,常驻 GPU)
struct PointLight {
float3 position;
float radius; // 影响半径(含衰减截止)
float3 color;
float intensity;
};
StructuredBuffer<PointLight> Lights;
StructuredBuffer<float4x4> LightViewProj; // 聚光灯矩阵
光源通常不超过几百字节,直接以 StructuredBuffer 传入,剔除与着色共用同一份数据,避免 CPU 重复拷贝。
5.2 原子分配与双缓冲
每帧剔除阶段先清零 lightCounts(可用 InterlockedExchange 或 ClearUAV),再用 InterlockedAdd 原子追加光源索引。为避免上一帧残留导致列表越界,通常:
- 光源表定长(
MAX_PER_CLUSTER上限,超出截断,代价仅是少几盏灯); - 或引入
atomicAdd后检查越界再写回; - 帧间双缓冲 UVA,剔除写 A 时着色读 B,杜绝读写冲突。
5.3 粗剔除 + 精剔除
务实做法是两级:CPU 端先做一次大粒度剔除(方向光排除、距离裁掉、包围球视锥),GPU 端再做 Cluster 精剔除。这样 GPU 剔除的负载被压到最低,避免"剔除本身成了新瓶颈"。
// GLSL:GPU 精剔除的伪代码流程
// 1. 重建 cluster 包围盒(用视锥射线插值)
// 2. 光源球体 AABB vs cluster AABB 重叠测试
// 3. 重叠 → 原子分配进 lightList
// 4. 供光照 Pass 直接查询
六、MSAA 与延迟渲染的矛盾
一句话:延迟渲染把几何信息扁平化成逐像素属性,像素内几何覆盖信息丢失,MSAA 的"多采样每像素"在光照阶段无从利用——这是延迟渲染最著名的代价。
6.1 为什么 MSAA 对延迟几乎无效
MSAA 只在像素内保存多个覆盖样本(coverage)与颜色样本,几何 Pass 写入 G-Buffer 时,每个子样本的法线/反照率未必相同(三角形跨越像素边缘)。延迟光照是"逐像素"的:光照 Pass 一次采样一张 G-Buffer 纹理,并不知道像素内有几个不同材质的子样本,于是所有子样本共享同一光照结果——锯齿没被消除,反而因 G-Buffer 采样在像素边缘产生硬切变。
6.2 可行解法
- 前向+(Forward+):几何与光照同 Pass,MSAA 天然生效,这是移动端与注重边缘质量的引擎的选择。
- 光线追踪 / 光追 AA:以 G-Buffer 为基础做 per-pixel 光追采样,规避 MSAA 问题但成本高,见 光线追踪与混合渲染。
- 后处理 AA(TAA):不依赖几何覆盖率,用时序累积消锯齿,见 抗锯齿全解析。AAA 延迟引擎几乎都用 TAA 而非 MSAA。
- MSAA G-Buffer(预过滤版):先在 MSAA 渲染目标上对每个子样本做纯光照(无阴影/SSR),再 resolve——本质上又退回了前向。
6.3 折中结论
延迟渲染与 MSAA 不可兼得,但 TAA + 延迟是工业标准组合:TAA 消除几何锯齿,延迟承载海量灯光,两者互补。追求"像素边缘完美"的场合(如 VR)则偏向 Forward+。
七、移动端优化
一句话:移动 GPU 的致命短板是带宽与发热,延迟渲染动辄数张 MRT 的读写会烧掉整帧预算,因此移动端要么用低精度/压缩 G-Buffer,要么干脆转向前向+。
7.1 移动 GPU 特点
移动 GPU(Mali、Adreno、Apple GPU)多为 Tile-Based:一小块 framebuffer 在片内 SRAM 中完成多次读写后才写回内存。对延迟渲染,MRT 命中率低、带宽敏感;光照 Pass 要重新采样整帧 G-Buffer,等价于把大片数据从内存读一遍,对功耗极其不利。
7.2 压缩与低精度 G-Buffer
- R11G11B10F:法线或颜色,节省一半带宽;
- RGB565 + 打包:把 albedo/rough/metal 压缩进 16 位通道;
- 八面体法线编码:法线 2 通道替代 3 通道,省 1/3;
- 共享指数(Shared Exponent,RGB9_E5):HDR 场景专用,8 位指数共享。
移动端 G-Buffer 压缩示例:
albedo : RGB565 (2B)
normal(oct) : RG8 (2B)
metal/rough : R8 + R8 (2B)
深度 : D16S8 (2B+)
合计 : ~8B/像素,对比桌面 ~20B/像素
7.3 更优解:延迟光照 / 延迟着色
- Deferred Lighting:只把法线与深度写入 G-Buffer(2~3 张),光照 Pass 输出漫反射与高光两张大图,再与材质合成——MRT 减少一半。
- Light Pre-Pass:只存深度+法线,光照 Pass 输出光照缓冲,材质 Pass 再逐物体合成。带宽更低但要多一次几何 Pass。
7.4 工程清单
- 优先 Tile-Based GPU 友好的
subpass(Vulkan)读取上帧局部缓冲; - 用
framebuffer fetch(移动 API 特性)在同一渲染目标内读写; - 半分辨率光照 + 上采样(对低端机明显收益);
- 用
vkCmdClearColorImage/ fast clear 减少无用写入; - 密切监控
bytes/frame指标,超预算优先降 G-Buffer 精度。
八、混合管线与工程选型
一句话:没有"唯一正确"的渲染方案——AAA 桌面常用 Clustered 延迟 + TAA + 前向半透明,移动端常用 Tiled 前向+,选型取决于目标平台、光源规模与边缘质量要求。
8.1 混合渲染管线
成熟的引擎把多种管线组合:
| 类别 | 管线选择 | 原因 |
|---|---|---|
| 不透明几何 | Clustered Deferred | 海量灯光、材质统一 |
| 半透明/体积 | Forward | MSAA、排序、透明混合 |
| 天空/粒子 | Forward | 简单、少光源 |
| 阴影 | 独立 Pass | 与光照解耦 |
| 后处理 | TAA + 色调映射 | 见 后处理管线 |
8.2 选型决策树
- 光源 < 50、几何简单:纯 Forward,简单稳定。
- 光源多、带宽充足(桌面):Clustered Deferred,光照上限高。
- 光源多、带宽受限(移动):Tiled Forward+,边缘质量好。
- VR / 强透视畸变:Forward+,规避延迟的 MSAA 坑。
- 需要极致贴图采样:Forward+ 让材质着色器自由扩展。
8.3 常见工程陷阱
- G-Buffer 精度不够导致漏光/条带(法线用 8 位、深度非线性);
- 光照 Pass 全屏三角形未做深度测试,造成带宽浪费;
- Tile 列表越界(光源索引超出 MAX)导致闪烁,须原子越界保护;
- 半透明物体被误入延迟管线,产生错误的透明叠加;
- 忘记给 Tile 设深度范围,前背景光源串味。
// C++:Vulkan 延迟管线两阶段提交示意
vkCmdBeginRenderPass(cmd, &geometryPass, ...); // MRT 写 G-Buffer
drawAllOpaque(cmd); // 材质 Pass
vkCmdEndRenderPass(cmd);
vkCmdBeginRenderPass(cmd, &lightingPass, ...); // 只读 G-Buffer
vkCmdBindPipeline(cmd, PIPELINE_FULLSCREEN);
vkCmdDraw(cmd, 3, 1, 0, 0); // 全屏三角形
vkCmdEndRenderPass(cmd);
总结
延迟渲染把"逐物体光照"升级为"逐像素光照",让场景能承载成百上千盏动态灯;Tiled/Clustered 剔除再用计算着色器把光源批量归类,让每像素只面对一小撮灯。前向、延迟、前向+ 各有取舍——延迟赢得光照上限但放弃 MSAA 与带宽,前向+ 用 Tile 分类换来两边通吃,移动端则必须在 G-Buffer 精度与功耗之间精打细算。
| 环节 | 核心技术 | 关键权衡 |
|---|---|---|
| G-Buffer | MRT 多目标写入、法线编码 | 精度 vs 带宽 |
| 光照组织 | Tiled / Clustered 剔除 | 剔除成本 vs 冗余 |
| 管线选择 | Forward / Deferred / Forward+ | 光源上限 vs 带宽/MSAA |
| 抗锯齿 | TAA(延迟)或 MSAA(前向) | 边缘质量 vs 兼容性 |
| 移动端 | 低精度 G-Buffer、Tile-Based 优化 | 带宽 vs 画质 |
实践路径:先在桌面把单光源延迟管线跑通,再接入 Tiled 剔除看千盏灯的表现,随后对比 Forward+ 的边缘质量,最后针对移动端做 G-Buffer 压缩与带宽统计。每一步都用 RenderDoc 检查 G-Buffer 中间缓冲,理解数据流从哪里流入、到哪里流出。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。