《延迟渲染与 Tiled/Clustered 光照剔除》

延迟渲染把材质属性写入 G-Buffer、把光照从逐物体解耦为逐像素,再以 Tiled/Clustered 网格在计算着色器中批量剔除光源,成为现代引擎渲染成千上万动态灯光的标准方案。本文讲解 G-Buffer 布局与延迟管线、Tiled/Clustered 剔除原理与实作、前向与延迟与前向+的取舍、MSAA 与延迟渲染的矛盾,以及移动端带宽优化策略。

传统前向渲染逐物体计算光照,灯光一多 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 布局

目标通道格式存储内容
GBuffer0RGBA8反照率 albedo(RGB)+ 遮挡 AO(A)3+1
GBuffer1RGBA16F法线(RGB,线性)+ 粗糙度(A)3+1
GBuffer2RGBA8金属度(R)+ 自发光(G)+ 置换/次表面标志(BA)1+1+2
DepthD32F深度(用于重建世界坐标)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

维度TiledClustered
网格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海量灯光、材质统一
半透明/体积ForwardMSAA、排序、透明混合
天空/粒子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-BufferMRT 多目标写入、法线编码精度 vs 带宽
光照组织Tiled / Clustered 剔除剔除成本 vs 冗余
管线选择Forward / Deferred / Forward+光源上限 vs 带宽/MSAA
抗锯齿TAA(延迟)或 MSAA(前向)边缘质量 vs 兼容性
移动端低精度 G-Buffer、Tile-Based 优化带宽 vs 画质

实践路径:先在桌面把单光源延迟管线跑通,再接入 Tiled 剔除看千盏灯的表现,随后对比 Forward+ 的边缘质量,最后针对移动端做 G-Buffer 压缩与带宽统计。每一步都用 RenderDoc 检查 G-Buffer 中间缓冲,理解数据流从哪里流入、到哪里流出。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 《glTF 资产格式与场景加载管线》
  2. 《实时全局光照:GI 探针、SSGI、体积光照与 Lumen 思路》
  3. 《Mesh Shader 新一代几何管线:Task/Mesh 着色器》