找回密码
 立即注册
查看: 417|回复: 6

Unity SRP 实战(五)基于 GPU 的剔除

[复制链接]
发表于 2022-4-19 06:36 | 显示全部楼层 |阅读模式
在大型游戏中往往会用草来刻画生机勃勃的场景。通过重复多次绘制单一的草 mesh 来实现草丛的效果:


通常的做法是创建若干个 GameObject 挂载到场景上,这样最简单直观但是会造成巨大的 Draw Call 开销。在绘制之前 CPU 会设置每个对象的渲染状态(比如材质,纹理和着色器)
由于所有的草都有相似的特点,比如使用同一个网格、使用同一个材质和 shader 进行绘制,故理论上我们只需要一次设置就能完成 N 个相似对象的绘制。基于这个思想,GPU instance 应运而生,它允许通过一次 Draw Call 绘制若干个同样材质的对象。而不同对象可能会有不同的位置、缩放和旋转,这些 pre instance attributes 可以通过 buffer 传达给 GPU 进而完成绘制
Unity 提供了 DrawMeshInstanced 接口帮助我们完成这一点,但是调用这个接口需要我们手动在 CPU 上计算每个 instance 的变换矩阵,理论上剔除工作还是在 CPU 上进行:


对于大规模物体的剔除是符合 SIMD 的思想的(数据不同指令相同)很明显可以通过 GPU 来加速这一过程,于是有了 DrawMeshInstancedIndirect,它允许我们用存储于 GPU 上的 buffer 来作为绘制参数。我们可以把每个物体的变换矩阵存储在 GPU buffer 上,在顶点着色器中读取 buffer 中的矩阵并完成顶点变换。
因为 buffer 是直接存储在 GPU 上的,在发起绘制命令之前我们可以通过 Compute Shader 来并行高效地执行剔除。使用未被剔除的物体的变换矩阵构造一个 validMatrixBuffer 并传给顶点着色器,大致的流程如下:


数据准备

需要创建一个 InstanceData 类来管理实体的数据,它可以作为一种 asset 持久的将数据保存在磁盘上面。每个 InstanceData 对于一组相同的实体,比如草、树、石头可以用 3 个 InstanceData 对象来描述:


通过在菜单创建对应的 asset 可以生成对应的实体数据。这里还通过编写继承自 Editor 的 InstanceDataEditor 类来给 Inspector 界面增加一个随机生成数据的回调函数:


绘制 Instance

这里尝试使用数据和函数分离的操作。创建一个 InstanceDrawer,它接受 InstanceData 对象,并且根据里面的数据进行绘制。在拿到 data 时先检查对应的 GPU buffer 有没有被创建,如果没有那就创建对应的 buffer


这里我们需要 3 个 buffer,首先是所有实体的变换矩阵 matrixBuffer,然后是剔除后的实体的变换矩阵 validMatrixBuffer,最后是一个绘制参数 argsBuffer,其中 argsBuffer[1] 是需要绘制的实体数目,我们暂且填个 0 上去。而 0、2、3 我们按照官方文档的示例代码将 Mesh 的信息填上
然后可以开始编写绘制函数。首先接受一个 InstanceData 对象,然后每 128 个 instance 为一组进行 dispatch,使用 ComputeShader 遍历 matrixBuffer 里面的数据并进行剔除,剔除保留可见物体的变换矩阵并存储到 validMatrixBuffer 中。最后将绘制指令输出到 CommandBuffer,代码如下:


现阶段我们还没有进行任何形式的剔除。所以在 Compute Shader 中原封不动地将矩阵全部搬运到 validMatrixBuffer,同时绘制计数器 argsBuffer[1] 也需要自增:


对于实体绘制,因为我们在顶点着色器中多做了一步操作。我们需要读取 validMatrixBuffer 中的变换矩阵,因此我们要新建一个 gbufferInstance.shader 来完成这件事情。它和我们的 gbuffer 着色器并无差异,仅仅是顶点着色器多操作了一下:


将这个 shader 应用到我们的草材质。最后在管线中设置目标 RT 然后调用 Draw 即可发起绘制:


至此一个人模狗样的草绘制就完成了。草没有产生投影是因为我仅在 gbuffer 阶段才调用 InstanceDrawer.Draw 进行绘制
视锥剔除

视锥剔除需要判断物体的包围盒是否在相机视锥体中,这一步骤可以抽象为判断包围盒的 8 个顶点在不在相机视锥体中。如果用 6 个平面来表示相机的视锥体:


那么问题可以转换为点 p 是否在 6 个平面的 “外侧”,这里外侧指的是法线方向:


对于一个平面,我们可以通过 Ax+By+Cz+D 来表示,其中 ABCD 为参数,可以通过 Vector4 来存储。其中 (A,B,C) 为平面的法向量。通过将点带入平面方程,判断结果的正负即可得到里侧还是外侧
而相机视锥体平面则通过 GeometryUtility.CalculateFrustumPlanes 可以很快得到这些数据:


同时计算物体的包围盒的八个顶点:


准备好这些数据并传到 Compute Shader 中,紧接着进行视锥剔除。首先读取该实体的变换矩阵,然后将物体的包围盒转到世界空间。包围盒的 8 个顶点依次判断是否在视锥体内部,如果一个都不在那么直接剔除该物体:


看看效果:


这里为了能在 Editor 相机也看到效果,我直接用的 Camera.main 传入 Draw 函数进行绘制。不管主相机还是 Editor 相机都是用主相机的数据进行剔除的
遮挡剔除

遮挡剔除的思路很简单。在绘制 instance 之前先得到场景的深度图,绘制的时候将包围盒投影到屏幕上判断屏幕深度和包围盒深度的关系。
对于包围盒内的所有像素都要检索一遍是非常耗时的,我们可以通过 Mipamp 来实现范围查询。一次查询就可以获得大块区域的深度值。


这里使用 mipmap 滤波器稍微有一点不同,我们在 down sample 的时候不是取深度的均值,而是取深度的最大值。深度最大值意味着最远的遮挡物,如果最远的遮挡物都挡住了要绘制的 instance 才能保守地认为该 instance 被遮挡。这样生成的带 mip 的深度缓冲名叫 Hi-z Buffer,
通过编写 hizBlit 着色器就可以很好的进行这个操作:


然后需要在管线中加一个 hizPass 去手动生成这些 mip,先创建一块带 mipmap 的纹理。纹理的大小必须取 2 的次幂所以要根据屏幕大小进行调整:


然后通过 Blit 命令来使用刚刚编写的 hizBlit 着色器手动生成单独的每一级的 mip 纹理数据,再通过 CopyTexture 把纹理拷贝到 hizBuffer 对应的 mip level 上:


检查一下,随便输出一级 mip 试试看没啥问题。注意这里我们使用的是当前帧的深度图生成的 mipmap 所以它不包含草:


将数据传递到 Compute Shader 之后就可以进行剔除了。首先将物体的 Bounding Box 投影到 NDC 空间,然后取得 xyz 方向上的最值。根据 hizBuffer 的原始大小 _size 计算包围盒在原图上占了多少个像素,然后根据像素数目计算 mip 等级。
假如包围盒在原图上占了 8 个像素,那么我们应该取第 log2(8)=3 级的 mip,因为 0、1、2、3 级 mipmap 在原图上占的像素依次是 1、2、4、8 个。对应的代码如下:


再看看效果:


遮挡剔除 Plus

遮挡剔除是用当前帧的深度图来进行的,这意味着我们只能剔除那些被普通物体遮挡的 instance,而并没有考虑到 instance 之间的互相遮挡关系。
我们可以使用上一帧的深度图和 vpMatrix 来做遮挡剔除,这需要我们在 gbuffer 阶段之后立刻进行 InstanceDrawPass,当所有几何体都绘制完成之后再进行 HizPass,大概的流程如下:


这里考虑到要在 Editor 相机中验证效果,所以仍然进行了特殊判断。此外要保存上一帧的 vp 矩阵
现在可以对比一下效果,主相机能够看到的画面是相同的,但是使用上一帧深度图渲染的场景考虑了 instance 之间的相互遮挡关系所以仅仅绘制少量的物体。因为草 mesh 中间是镂空的,这里为了更鲜明的表现遮挡关系而使用了密闭的球体:


当然这么做也有问题。比如快速移动镜头的时候因为当前帧和历史帧差距过大,会出现物体闪烁的情况。一种完美的解决方案是同时存储已被剔除、未被剔除的物体,然后做两次剔除判断
另一种比较经济的解决方案是加个动态模糊苟一下,用户看不出来就算成功!
小结

虽实现了 GPU 剔除但问题仍然很多。懒得总结了,开摆了



参考与引用

[1] MaxwellGeng, "Hi-Z GPU Occlusion Culling"
[2] 王江荣, "【Unity】使用Compute Shader实现Hi-z遮挡剔除(Occlusion Culling)"
[3]  leonwei, "Unity中基于Gpu Instance进行大量物体渲染的实现与分析"
[4] Michael Palmos, "Drawing Thousands of Meshes with DrawMeshInstanced / Indirect in Unity"
[5] 隐士低手, "游戏场景管理(二)视锥体剔除"
[6] ellioman, "Indirect-Rendering-With-Compute-Shaders"
[7] logicalbeat, "【Unity】【数学】視錐台(Frustum)について(第2回)"

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-4-19 06:42 | 显示全部楼层
真得是画 草,[飙泪笑]
发表于 2022-4-19 06:50 | 显示全部楼层
上一帧depth reproj的延迟问题可以改成用上一帧没被剔除掉的mesh来画这一帧的occlusion depth(假设帧连续的话,上一帧可见的大概率这一帧也可见)ue5就是这么做的。
发表于 2022-4-19 06:58 | 显示全部楼层
emm 这里有点没理解过来 [可怜] 是这个思路吗:将物体 reproj 到上一帧的 depth 上,分为被遮挡和未遮挡两组,先画未遮挡的物体以更新 hiz depth 然后再用新的 depth 判断已遮挡的里面是否有剔错的
发表于 2022-4-19 07:07 | 显示全部楼层
你说的这个是大革命的做法,我说的这个是ue5的做法
具体用那个还是看实测吧

不过说实话单纯的草我觉得没必要做自遮挡剔除,毕竟大量镂空,这生成出来的hiz buffer你加不加草我觉得都没啥区别
发表于 2022-4-19 07:15 | 显示全部楼层
不是,没有reproj了。reproj有两个问题,一个是延迟(帧数越低越明显),一个是有洞,导致生成的hiz保守剔除效率很低。
后者解决方案ubi是每帧选一定数量最近的occludee mesh再画一遍,希望能覆盖小洞。前者这样没法解决。那reproj depth肯定会有延迟,ubi所以就剔除两边做保守处理。但既然思路是(上一帧和这一帧差别不大所以我来复用遮挡信息的话),你可以直接用上一帧没被剔除掉的mesh来画这一帧的depth用于剔除。
具体操作类似这样。上一帧结束得到已剔除组A和未剔除组B
这一帧画B组的mesh(当然是frustum cull之后的,因为这是可以确定的)到depth上(因为用的当前帧的mvp transform,所以不会有延迟)然后用组B的depth结果对 当前帧所有frustum内mesh进行剔除,得到已剔除组A’和未剔除组B’绘制B’(这个根据管线不同可以用不同优化,比如如果是depth prepass的话那之前已经画过的应该去掉)
发表于 2022-4-19 07:17 | 显示全部楼层
讲的很详细,这下彻底明白啦!感谢!
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-16 20:40 , Processed in 0.121879 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表