「场景里能放多少个动态光源?」这个问题长期折磨着实时渲染工程师。传统前向渲染(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 × tilesY | slicesZ | 总簇数 | 适用 |
|---|---|---|---|---|
| 1920×1080 | 120 × 68 | 16 | 130K | 通用桌面 |
| 1920×1080 | 120 × 68 | 24 | 196K | 光源深度密集 |
| 2560×1440 | 160 × 90 | 24 | 346K | 高端桌面 |
| 移动端 1080p | 60 × 34 | 8 | 16K | 低开销优先 |
经验法则:每簇像素投影面积保持 16×16 左右,深度切片数控制在 16~32。移动端要显著削减,因为分桶 Pass 的带宽与原子操作开销在 TBDR 架构上更敏感。
四、光源分配与 GPU 数据结构
一句话:把「簇 → 光源」的关联压缩成两张紧凑的 GPU 缓冲,是 Forward+ 性能的关键。
4.1 cluster 与光源的求交
分桶 Pass 是典型的「散写」问题:多个线程可能同时往同一个簇追加光源,需要原子操作。优化策略:
- 先统计再填充:第一遍用
atomicAdd统计每个簇的光源数,第二遍按前缀和填充索引,避免每个线程持锁; - 光源包围体剔除:先用大范围 AABB 快速排除明显不相交的光源;
- 限制每簇光源数:设上限(如 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 性能数据对比
| 场景 | Forward | Deferred | Forward+ |
|---|---|---|---|
| 8 光源 | 最快 | 慢(带宽) | 略慢于 Forward |
| 64 光源 | 极慢 | 快 | 快 |
| 256 光源 | 不可用 | 快 | 快 |
| 大量透明物体 | 好 | 差 | 好 |
| 高 MSAA | 好 | 差 | 好 |
| 带宽受限(移动端) | 好 | 差 | 中 |
6.2 适用场景
- 推荐:开放世界、大量动态光源、需要透明与 MSAA、桌面与主机;
- 谨慎:极端带宽受限的低端移动设备——分桶 Pass 本身也有开销,簇数不宜过多;
- VR:双眼渲染时簇结构可复用,是 VR 前向渲染的常见选择,可参考 Unreal VR 渲染管线 。
6.3 常见陷阱
- 簇数过多:
120 × 68 × 24 ≈ 20 万簇,分桶开销可能超过收益,需按分辨率与光源数调参; - 每簇上限溢出:
MAX_LIGHTS_PER_CLUSTER太小会丢光源(画面闪烁),太大浪费显存; - 忽略 Overdraw:光源分桶解决了光源遍历,但 Overdraw 仍然存在,需配合 客户端 Overdraw 与 UI 治理 的思路治理;
- 深度切片选错:均匀切片在近处精度差,靠近相机的光源容易误判;
- 忽略分桶 Pass 自身开销:分桶是一次全屏计算,若光源数与簇数都很大,其开销可能抵消光照收益,务必用 GPU 计时单独测量。
6.4 与其他剔除技术的协同
Forward+ 的光源分桶与几何侧的剔除是正交的两件事:前者剔除「不影响片元的光源」,后者剔除「不可见的物体」。二者可同时启用,且都与 可见性剔除系统 中的视锥/遮挡剔除配合,共同把一帧的工作量压到最小。
小结
集群前向渲染的核心洞察是:光照成本应该与「局部光源密度」而非「全局光源数」相关。它的实现可以浓缩为四步:
- 把视锥切成三维簇(屏幕网格 × 指数深度切片);
- 用计算着色器为每个簇预计算相交的光源列表;
- 着色时片元反查所在簇,只遍历该簇的光源;
- 与阴影、透明、MSAA 协同,避免重复开销。
落地时的优先级是:先做 Tiled(二维),确认收益后再升级 Clustered(三维)。二维版本已能覆盖大多数场景,三维版本的额外开销只在光源在深度方向分布密集时才划算。这套机制与 Vulkan 高级渲染技术 中的计算着色器实践可以互相印证,建议对照阅读。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。