|
分簇延迟光照(Cluster Based Deferred Lighting)是一种流行的光照计算的优化策略,它能够允许海量的同屏光源。CBDL 将相机视锥体分为若干簇,并为仅为每个簇分配若干有效的光源,可以避免大量无效的光照计算。分簇光照分为两步,预处理和着色。在预处理阶段我们要:
- 分割相机视锥体,得到若干 Cluster
- 对每个 Cluster,遍历所有光源并求交,得到影响该 Cluster 的 “有效光源” 列表
而在着色阶段的流程则比较简单。首先根据像素坐标计算像素所属 Cluster,然后遍历该 Cluster 的 “有效光源” 列表,逐一计算光照。因为对每个簇、每个光源的操作都是相同的,只是簇和光源的数据(position,color 等)不同,这符合 SIMD 的思想。通常使用 Compute Shader 并行地进行分簇和光源分配,所以有不错的运行效率。
数据结构
需要准备四个 Buffer,分别存放 Cluster 信息表、光源信息表、光源分配结果表和光源分配索引表。所有的 Buffer 都是一维的,在使用时需要根据三维 Cluster ID (i, j, k) 降维到一维 ID 再取数据:
光源分配索引表(assignTable)中每个元素对应一个 Cluster,每个元素存储了 start 和 count,表示该 Cluster 受到哪些光源的影响。
在光源分配结果表(lightAssignBuffer)的 [start, start+count) 区间存储的是这些光源的 id,即光源在 lightBuffer 中的下标,所以光源分配结果表以 sizeof uint 为 stride
具体的索引过程如下。首先通过 Cluster ID 查 assignTable,然后遍历 lightAssignBuffer 获取灯光 ID,再根据灯光 ID 查 lightBuffer 获取灯光信息:
因为每个 Cluster 要支持若干盏灯,需要建立一个巨大的 lightAssignBuffer 以存储光源分配结果。每个 Cluster 最多支持 maxNumLightsPerCluster 盏灯,因为在 buffer 中我们只分配了那么多空间。
分簇
每个簇都是一个梯形台,是视锥体的一部分。可以粗暴的用 8 个点来表示:
第一个问题就是如何划分视锥体,这里我沿着 XY 方向均匀的分成(numClusterX,numClusterY)的 tile,然后再沿着 Z 方向均匀切分 numClusterZ 块,即 View Space 上的均分:
使用 Compute Shader 并行地进行分簇,每一个簇对应一个线程。在 Z 方向上分配 numClusterZ 个线程组,每个线程组包含 numClusterX * numClusterY 个线程:
分割方法如下。这里通过 SV_GroupThreadID 得到 Cluster 的 xy 索引,通过 SV_GroupID 得到 z 方向的索引,组成三维 Cluster ID,用(i,j,k)表示。然后:
- 通过 i、j 得到 NDC 空间下该 Cluster 的 xy 二维 Rect
- 分别用 0 和 1 做深度,将 Rect 反投影得到世界空间下近、远截面的梯形台
- 通过 k 对梯形台进行切分,截取我们要的第 k 级 cluster
- 将结果保存到 Compute Buffer
步骤比较简单,但我的语言表达能力捉急,于是大概流程图如下:
因为 z 方向是均匀切分,没有什么数学公式,故代码比较简单。注意 Cluster ID 的转换即可,这里我们将三维索引降维平铺到一维的 Buffer 中。Compute Shader 的代码如下:
再来看看 C# 的代码,因为每个线程组负责 x * y 个 Cluster,所以我们设置好参数和目标 Buffer 之后只需 Dispatch 出 z 组线程即可:
做到这一步之后我们的 clusterBuffer 中应该存放了所有的 Box,可以通过 Compute Buffer 的 GetData 回读数据到 CPU 然后手动 Debug.DrawLine 绘制每个簇。把它们显示出来大概是这样的:
其实一般相机参数不变的话,可以在 Pipeline 启动的时候在 View Space 计算一次 Cluster,之后每一帧用 Cluster 的时候再用相机矩阵转到 World Space,这里我为了 Debug 方便就直接一步到位(其实是偷懒
传递光源信息到 GPU
Compute Buffer 允许用户使用 SetData 在 CPU 端设置数据。在每一帧都通过 Resources.FindObjectsOfType 获取全部的光源列表,然后更新光源信息到 Buffer,同时向 Shader 传递有效的光源数量。代码如下:
也可以使用 Unity SRP API 提供的裁剪方法对视锥体外的光源进行裁剪,传递裁剪过后的光源列表能有效提高后续求交操作的效率。注意这里裁剪之后返回的是 VisuableLight 而不是 Light 对象,要重载多一个方法:
当然对于静态场景没有必要每一帧都更新光源信息,在一开始写入一次 Buffer 就够了,因为 FindObjectsOfType 的 CPU 开销是巨大的。根据需求做一些取舍
光源求交
和划分视锥体时一样,每个 Cluster 对应一个线程。每个线程首先根据自己的线程组 ID 和组内 ID,得到 Cluster 的三维索引(i,j,k),然后降维到一维并查 Cluster Buffer 读取世界坐标下的八个顶点坐标。
拿到 Cluster 数据后,遍历所有光源并求交,如果相交则将光源 ID 写入光源分配结果表(lightAssignBuffer),最后将索引写入光源分配索引表(assignTable)
这里用了另一个 Compute Shader,求交也是简单粗暴的判断下 8 个点在不在球内部,代码比较简单:
在这一次 Dispatch 之后我们同样通过 Compute Buffer.GetData 回读光源分配索引表(assignTable)的数据到 CPU,如果 count 大于 0 说明该 Cluster 接受光照。Debug 一下,因为 clusterBuffer 和 assignTable 下标是一一对应的,可以很方便用不同的颜色标识受到光源的 Cluster:
光照计算
光照计算比较简单,从光源分配索引表(assignTable)读索引,然后遍历光源分配结果表(lightAssignBuffer)得到光源下标,再查 lightBuffer 得到光源信息,最后计算直接光照。
这里有个要注意的地方,因为默认开启了 Reverse Z 所以越靠近相机的 Cluster 的 Z 越大。在读取数据的时候首先得到线性深度 d_lin,但是 Linear01Depth 帮我们把深度转回了越近相机 Z 越小,故要再反转回去再使用。然后就是遍历光源计算光照,这里简单套个灯光衰减公式:
通过 Shader.SetGlobalBuffer 将数据传递到着色器。此外如果在 Fragment Shader 中使用 Compute Buffer,声明的时候要把 RWStructuredBuffer 的 RW 去掉:
代码如下:
效果:
可以优化的地方
X、Y、Z 方向均只分了 16 级,可以多分一点,Cluster 越小越精确
没有必要每帧都计算 Cluster,可以先在 View Space 计算,使用时通过矩阵转换到 World Space,这样每帧只用 Dispatch 一次
Cluster Z 方向的划分可以改为指数划分,这样分出来更均匀
静态场景没有必要每帧都更新全部光源信息,因为传送 CPU 数据到 GPU 会有可观的开销
可以根据深度图进行 Cluster 的剔除,在分配光源时无需遍历完全被遮挡的 Cluster
此外因为要用到主相机的视锥体,所以我们的阴影和光照在 Editor Mode 下不能很好的工作,会有一些(大量)Bug
后记
本来打算过年前发完这篇博客的,奈何家里活动比较多。年前聚会买花烧香拜佛买菜装春联,年后走亲戚逛公园看电影。再加上我代码写的又垃圾,总是遇到奇奇怪怪的 Bug,查文档查谷歌就花了不少时间,一次又一次打破自己设下的 DDL,写到最后直接开摆了,文章也写的比较潦草。新年伊始就开摆了,不愧是我!
参考与引用
[1] MaxwellGeng, "Cluster Based Deferred Lighting"
[2] 未名客, "【渲染流程】Cluster_Unity实现概述"
[3] Ola Olsson, "Clustered Deferred and Forward Shading"
[4] Emil Persson, "Practical Clustered Shading"
[5] 王江荣, "Unity中ComputeShader的基础介绍与使用"
[6] 翎玄, "Unity Shader (一) Compute Shader"
[7] Catlike Coding, "Lights Single-Pass Forward Rendering" |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|