集群前向渲染与光源管理:Forward+、Tiled 与 Clustered 光照

当场景有上百个动态光源时,传统前向渲染要为每个物体遍历全部光源,延迟渲染又受限于 G-Buffer 带宽与透明度处理。集群前向渲染(Clustered Forward / Forward+)把视锥切成三维簇,为每个簇预计算光源列表,让着色时只遍历真正影响该片元的光源。本文讲解 Tiled 分桶、Clustered 三维切分、光源分配数据结构与着色阶段查找,并给出性能取舍。

「场景里能放多少个动态光源?」这个问题长期折磨着实时渲染工程师。传统前向渲染(Forward Rendering)对每个物体遍历全部光源,光源一多,着色器里就是一个巨大的循环,帧率断崖式下跌。延迟渲染(Deferred Rendering)把光照推迟到屏幕空间,一次处理所有光源,但代价是 G-Buffer 的带宽开销,而且对透明物体无能为力。

集群前向渲染(Clustered Forward Rendering,也叫 Forward+、Tiled Forward)是这场拉锯战里目前最主流的答案:它把视锥切成成千上万个三维「簇(Cluster)」,预计算出每个簇被哪些光源影响,着色时片元只需遍历自己所在簇的光源列表。这样既保留了前向渲染的灵活性(透明、MSAA、材质自由),又把光源遍历成本降到与「局部相关」而非「全局相关」。本文拆解它的完整实现。

一、从 Forward 到 Deferred 再到 Forward+

一句话:三种管线的核心分歧在于「光源遍历发生在哪里、遍历多少个」——Forward 每物体遍历全部,Deferred 每像素遍历全部,Forward+ 每像素只遍历局部。

1.1 三种管线的演进

管线光照时机光源遍历成本透明支持G-Buffer 带宽
Forward每个物体绘制时物体数 × 光源数天然支持无
Deferred屏幕空间一次性像素数 × 光源数需额外前向 Pass高
Forward+每个物体绘制时 + 局部光源像素数 × 局部光源数天然支持仅光照簇结构

1.2 各自的光源处理方式

  • Forward:着色器里 for (int i = 0; i < numLights; ++i),numLights 是全场景光源数。10 个光源尚可,100 个就崩。
  • Deferred:先渲染 G-Buffer(位置、法线、材质),再对每个像素遍历所有光源。成本与光源数线性相关,但每像素成本固定,且透明物体要走单独的前向 Pass。
  • Forward+:预计算「哪些光源影响哪些空间区域」,着色时只遍历该区域的光源。成本与局部光源密度相关。

1.3 为什么 Forward+ 回归前向

Forward+ 的价值在于鱼与熊掌兼得:它保留了前向渲染对透明、MSAA、复杂材质的支持,又获得了接近延迟渲染的光源扩展性。代价是需要一次额外的光源分桶 Pass,以及维护一套簇数据结构。这套机制与 延迟渲染与 Tiled/Clustered 光照剔除 中的思路一脉相承,但完全在前向管线内运作。

1.4 一个量化的对比

假设 1000 个物体、200 个动态光源、1920×1080:

Forward:每物体遍历 200 光源 → 着色器循环次数 ~ 200 次/片元
Deferred:每像素遍历 200 光源 → 同样 200 次/像素,但 G-Buffer 带宽翻倍
Forward+:每片元只遍历其所在簇的光源(平均 5~20 个)→ 降一到两个数量级

差距的核心不在光源评估本身有多贵,而在遍历规模。Forward+ 把「全局 200」压到「局部个位数」,这才是它能在几百光源下保持帧率的原因。

二、Tiled 光照分桶原理

一句话:Tiled 把屏幕切成二维网格(如 16×16 像素一块),为每块计算影响它的光源列表——这是从二维角度逼近「局部相关」。

2.1 屏幕空间分块

设屏幕 1920×1080,tile 大小 16×16:

tile 列数 = ceil(1920 / 16) = 120
tile 行数 = ceil(1080 / 16) = 68
总 tile 数 = 120 × 68 = 8160

每个 tile 维护一个光源索引列表。一个覆盖半个屏幕的大光源会出现在大量 tile 的列表里——这正是 Tiled 的弱点:无法区分深度。

2.2 分桶的数据结构

经典实现用两张表:

tileLightCount[tileIdx]           // 每个 tile 的光源数
tileLightIndices[offset + i]      // 光源索引,紧凑存储

分桶流程(计算着色器):

// 每个线程负责一个 tile
void main() {
    ivec2 tile = ivec2(gl_GlobalInvocationID.xy);
    uint count = 0;
    for (uint i = 0; i < numLights; ++i) {
        if (lightIntersectsTile(lights[i], tile)) {
            uint slot = atomicAdd(tileLightCount[tile.y * tilesX + tile.x], 1u);
            tileLightIndices[tileIdx * MAX_LIGHTS_PER_TILE + slot] = i;
        }
    }
}

2.3 带宽与 tile 大小权衡

tile 大小精度内存开销备注
8×8高大tile 数多,分桶开销高
16×16中中最常用的折中
32×32低小大光源误判多,浪费遍历

tile 越大,深度不敏感的误判越严重(近处 tile 会被远处大光源「污染」)。

2.4 分桶的两遍法

单遍分桶需要用原子操作追加,且每 tile 需要预留最大容量(浪费显存)。更稳健的是两遍法:

// 第一遍:只统计每个 tile 的光源数(无需预留)
for (uint i = 0; i < numLights; ++i)
    if (intersects(lights[i], tile)) atomicAdd(tileLightCount[tileIdx], 1u);

// 中间:对 tileLightCount 做前缀和,得到每个 tile 的起始偏移
// 第二遍:按偏移填充索引
for (uint i = 0; i < numLights; ++i)
    if (intersects(lights[i], tile)) {
        uint slot = atomicAdd(tileWriteCursor[tileIdx], 1u);
        tileLightIndices[tileStart[tileIdx] + slot] = i;
    }

两遍法把显存占用从「tile 数 × 每 tile 上限」降到「总关联数」,且避免了溢出丢光。

2.5 光源包围体的选择

点光源用球体、聚光灯用圆锥体(或视锥)、方向光则特殊处理——方向光影响所有像素,通常单独在着色器里硬编码,不参与分桶。面光源(矩形/管状)需要用其包围盒做保守测试。

三、Clustered 光照:三维视锥切分

一句话:Clustered 在 Tiled 的屏幕二维网格上再加一层深度切片,把视锥切成三维簇,彻底解决深度不敏感问题。

3.1 从 2D tile 到 3D cluster

一个 cluster 由三元组 (x, y, z) 索引:

  • x, y:屏幕空间网格坐标(同 Tiled);
  • z:深度切片索引(新增维度)。
cluster 数 = tilesX × tilesY × slicesZ
例:120 × 68 × 24 = 195840 个簇

每个簇对应视锥里的一小块三维空间,光源只与真正相交的簇关联。

3.2 视锥切分与深度切片

深度切片有两种分法:

  • 均匀切片(Uniform):z 线性划分,简单但近处精度低;
  • 指数切片(Exponential):按 z^exponent 分布,近处密、远处疏,与透视投影的深度分布匹配。
// 指数深度切片:第 i 片的远平面距离
float sliceNear(uint i) { return near * pow(far / near, float(i) / float(slicesZ)); }
float sliceFar (uint i) { return near * pow(far / near, float(i + 1) / float(slicesZ)); }

指数分片让每个簇在屏幕上的投影面积大致相等,避免近处簇过小、远处簇过大。

3.3 簇的 AABB 计算

分桶时,需要把「光源」与「簇」的相交判断化归为 AABB 或球体测试。常用做法是把光源的包围球变换到视图空间,与簇在视图空间下的 AABB 做相交。簇 AABB 可用视锥参数预计算:

// 由 cluster 索引反推其在视图空间下的 min/max
vec3 clusterMin = viewFrustumMin(tileX, tileY, sliceZ);
vec3 clusterMax = viewFrustumMax(tileX, tileY, sliceZ);

3.4 簇数量的调参指南

簇数不是越多越好,它是「精度」与「分桶开销」的权衡:

分辨率tilesX × tilesYslicesZ总簇数适用
1920×1080120 × 6816130K通用桌面
1920×1080120 × 6824196K光源深度密集
2560×1440160 × 9024346K高端桌面
移动端 1080p60 × 34816K低开销优先

经验法则:每簇像素投影面积保持 16×16 左右,深度切片数控制在 16~32。移动端要显著削减,因为分桶 Pass 的带宽与原子操作开销在 TBDR 架构上更敏感。

四、光源分配与 GPU 数据结构

一句话:把「簇 → 光源」的关联压缩成两张紧凑的 GPU 缓冲,是 Forward+ 性能的关键。

4.1 cluster 与光源的求交

分桶 Pass 是典型的「散写」问题:多个线程可能同时往同一个簇追加光源,需要原子操作。优化策略:

  1. 先统计再填充:第一遍用 atomicAdd 统计每个簇的光源数,第二遍按前缀和填充索引,避免每个线程持锁;
  2. 光源包围体剔除:先用大范围 AABB 快速排除明显不相交的光源;
  3. 限制每簇光源数:设上限(如 256),超出则按距离或强度截断。

4.2 紧凑化的光源索引表

数据结构与 Tiled 类似,但索引按 (x, y, z) 三维展开:

clusterLightCount[clusterIdx]                 // 每簇光源数
clusterLightIndices[clusterOffset + i]        // 光源索引,紧凑

clusterOffset 由前缀和(Prefix Sum)计算,保证索引区连续无空洞。

4.3 计算着色器实现骨架

layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

void main() {
    ivec3 cluster = ivec3(gl_GlobalInvocationID);
    if (any(greaterThanEqual(cluster, ivec3(clustersX, clustersY, clustersZ)))) return;

    uint clusterIdx = cluster.z * clustersX * clustersY
                    + cluster.y * clustersX + cluster.x;
    AABB clusterAABB = computeClusterAABB(cluster);

    for (uint i = 0; i < numLights; ++i) {
        if (aabbIntersectsSphere(clusterAABB, lights[i].position, lights[i].radius)) {
            uint slot = atomicAdd(clusterLightCount[clusterIdx], 1u);
            if (slot < MAX_LIGHTS_PER_CLUSTER)
                clusterLightIndices[clusterIdx * MAX_LIGHTS_PER_CLUSTER + slot] = i;
        }
    }
}

4.4 光源类型与数据的紧凑打包

为了降低带宽,光源数据常打包成紧凑结构(如 32 字节对齐):

struct Light {
    vec3  position;   float radius;
    vec3  color;      float intensity;
    vec3  direction;  float spotCos;   // 聚光灯余弦角
    uint  type;       uint shadowIdx;  // 0=点光 1=聚光 2=面光
};

把 type 与 shadowIdx 打包进同一缓存行,能让着色器一次读取就拿到评估光源所需的全部字段,减少随机访问。

五、着色阶段的光源查找

一句话:前向着色时,片元根据其世界/视图位置反查所在簇,遍历该簇的光源列表累加光照。

5.1 从片元位置求 cluster 索引

在片段着色器里:

uint clusterIndexFor(vec3 viewPos) {
    // 1) 屏幕坐标 → tile 索引
    vec4 clip = proj * vec4(viewPos, 1.0);
    vec2 ndc = clip.xy / clip.w;
    ivec2 tile = ivec2((ndc * 0.5 + 0.5) * vec2(tilesX, tilesY));
    // 2) 视图空间深度 → 切片索引
    float sliceF = log(viewPos.z / near) / log(far / near);
    uint slice = uint(sliceF * float(slicesZ));
    return slice * tilesX * tilesY + tile.y * tilesX + tile.x;
}

5.2 遍历与累加

uint clusterIdx = clusterIndexFor(viewPos);
uint count = clusterLightCount[clusterIdx];
vec3 color = vec3(0.0);
for (uint i = 0; i < count; ++i) {
    uint lightIdx = clusterLightIndices[clusterIdx * MAX_LIGHTS_PER_CLUSTER + i];
    color += evaluateLight(lights[lightIdx], surface, viewPos);
}

相比遍历全部光源,这个循环的迭代次数通常只有个位数到几十,是数量级的改善。

5.3 与阴影的协同

Forward+ 与阴影贴图的配合有讲究:光源分桶只解决「哪些光源影响这个片元」,阴影贴图的采样仍是每个光源一次。常见优化是在分桶阶段就记录光源的阴影索引,并在着色时按需采样,避免为不影响片元的光源做无谓的阴影查找。

5.4 深度误差与边缘伪影

由片元位置反查簇索引时,若 viewPos.z 落在簇边界的另一侧(浮点误差),会取到相邻簇,导致光照在簇边界处出现细微的硬边。缓解手段:

  • 簇边界略微外扩:计算 AABB 时留一点冗余(如 1.05 倍),让相邻簇的光源集有重叠;
  • 保守分桶:相交测试用「球体 vs AABB 的保守距离」,宁可多关联一个光源也不漏;
  • 可视化调试:把簇索引映射成伪彩色输出到屏幕,肉眼检查切片分布是否合理。
// 调试可视化:把簇索引着色
vec3 clusterColor = vec3(float(tile.x) / tilesX,
                         float(tile.y) / tilesY,
                         float(slice) / slicesZ);
debugOut = vec4(clusterColor, 1.0);

六、性能对比与工程取舍

一句话:Forward+ 在「中等光源密度 + 需要透明/MSAA」的场景下最优,极端场景未必胜过延迟渲染。

6.1 性能数据对比

场景ForwardDeferredForward+
8 光源最快慢(带宽)略慢于 Forward
64 光源极慢快快
256 光源不可用快快
大量透明物体好差好
高 MSAA好差好
带宽受限(移动端)好差中

6.2 适用场景

  • 推荐:开放世界、大量动态光源、需要透明与 MSAA、桌面与主机;
  • 谨慎:极端带宽受限的低端移动设备——分桶 Pass 本身也有开销,簇数不宜过多;
  • VR:双眼渲染时簇结构可复用,是 VR 前向渲染的常见选择,可参考 Unreal VR 渲染管线 。

6.3 常见陷阱

  1. 簇数过多:120 × 68 × 24 ≈ 20 万簇,分桶开销可能超过收益,需按分辨率与光源数调参;
  2. 每簇上限溢出:MAX_LIGHTS_PER_CLUSTER 太小会丢光源(画面闪烁),太大浪费显存;
  3. 忽略 Overdraw:光源分桶解决了光源遍历,但 Overdraw 仍然存在,需配合 客户端 Overdraw 与 UI 治理 的思路治理;
  4. 深度切片选错:均匀切片在近处精度差,靠近相机的光源容易误判;
  5. 忽略分桶 Pass 自身开销:分桶是一次全屏计算,若光源数与簇数都很大,其开销可能抵消光照收益,务必用 GPU 计时单独测量。

6.4 与其他剔除技术的协同

Forward+ 的光源分桶与几何侧的剔除是正交的两件事:前者剔除「不影响片元的光源」,后者剔除「不可见的物体」。二者可同时启用,且都与 可见性剔除系统 中的视锥/遮挡剔除配合,共同把一帧的工作量压到最小。

小结

集群前向渲染的核心洞察是:光照成本应该与「局部光源密度」而非「全局光源数」相关。它的实现可以浓缩为四步:

  1. 把视锥切成三维簇(屏幕网格 × 指数深度切片);
  2. 用计算着色器为每个簇预计算相交的光源列表;
  3. 着色时片元反查所在簇,只遍历该簇的光源;
  4. 与阴影、透明、MSAA 协同,避免重复开销。

落地时的优先级是:先做 Tiled(二维),确认收益后再升级 Clustered(三维)。二维版本已能覆盖大多数场景,三维版本的额外开销只在光源在深度方向分布密集时才划算。这套机制与 Vulkan 高级渲染技术 中的计算着色器实践可以互相印证,建议对照阅读。

继续阅读

探索更多技术文章

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

全部文章 返回首页

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

  1. 移动端渲染与功耗优化:TBDR、带宽与热节流治理
  2. SDF 与距离场渲染:从 Raymarching 到字体、阴影与碰撞
  3. 着色器编译与 SPIR-V 工具链:从源码到 GPU 指令的完整链路