stonstad 发表于 2022-2-14 13:53

Unity SRP 实战(四)Cluster Based Lighting


分簇延迟光照(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 越小,故要再反转回去再使用。然后就是遍历光源计算光照,这里简单套个灯光衰减公式:

https://www.zhihu.com/equation?tex=atten%3D%281-%28%5Cfrac%7Bd%5E2%7D%7Br%5E2%7D%29%5E2%29%5E2++%5C%5C
通过 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,写到最后直接开摆了,文章也写的比较潦草。新年伊始就开摆了,不愧是我!


参考与引用

MaxwellGeng, "Cluster Based Deferred Lighting"
未名客, "【渲染流程】Cluster_Unity实现概述"
Ola Olsson, "Clustered Deferred and Forward Shading"
Emil Persson, "Practical Clustered Shading"
王江荣, "Unity中ComputeShader的基础介绍与使用"
翎玄, "Unity Shader (一) Compute Shader"
Catlike Coding, "Lights Single-Pass Forward Rendering"

Doris232 发表于 2022-2-14 14:01

xy方向的切分如果不对齐到8x8或者16x16像素的话性能损失会比较大

HuldaGnodim 发表于 2022-2-14 14:02

大佬能详细说下原因嘛,这里有点没搞懂 [捂脸] 是和 GPU 的Warp 有关吗?

RecursiveFrog 发表于 2022-2-14 14:09

屏幕上一个tile会在同一个线程组里执行fs,tile大小从8x4到32x32,一个线程组需要尽量在逻辑分支时走到同一个分支上

Zephus 发表于 2022-2-14 14:13

明白了!谢谢指点 [拜托]

redhat9i 发表于 2022-2-14 14:15

对的
可以看doom eternal,还有cod的分享,尽量一个wavefront里的访存等操作都统一,这样cache效率高

RhinoFreak 发表于 2022-2-14 14:21

cluster culling开销大还是挺dt的
还是tile + z binning爽,实际上z轴简单用距离做个mask大部分情况还是够用的。
我以前也陷入误区,以为想搞各种froxel啥的技术还是得转cluster based,实际上2.5d tile based还是挺不错的

Ilingis 发表于 2022-2-14 14:23

嗯嗯是的,Z 方向划分的话 Cluster 数量直接爆增 [飙泪笑] 而且仔细一想也很少出现超多小灯光挤在一个 Tile 里面的情形

七彩极 发表于 2022-2-14 14:33

开放世界还是有可能的[飙泪笑]
不过那种灯之间距离远的,用个简单的bit mask做快速效果够够了
页: [1]
查看完整版本: Unity SRP 实战(四)Cluster Based Lighting