[Unity] 基于Unity3D的大地形研究(1):Cluster Async Load

查看:1002 |回复:0 | 2020-12-5 20:44:31

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

x
之前的序言中讲过,大地形的绘制往往伴随着巨量的模型,美术资源,由于现代计算机的硬件限制,毫无疑问是不能同步加载所有模型的。这时候我们就需要流式加载,只加载玩家能看得到的地方,不加载看不到的地方。
在传统的场景管理中,比如主流的商业引擎UE4和U3D中,是使用Multi Scene/Level streaming,而在传统的渲染管线中,物体以GameObject/Actor为单位进行加载,同时将脚本信息进行序列化存储与反序列化实例化,如U3D中通过语言的反射特性,UE4中通过在C++上扩展的序列化。
这样的管理方式受到业界的普遍认可,是比较流行的方式,我们在之后的大地形加载中也会基于这类思想。然而,传统的场景管理方法对静态物体并不是很友好。首先,脚本与物体的序列化依然要依赖主线程,这常是因为脚本需要在主线程同步的被调用,实例化同样需要在主线程同步进行,否则极易出现线程安全问题,因此虽然这种摆场景分块加载的方法对美术制作人员非常友好,但其对于计算机硬件来说却不算友好,因为物体极有可能比较琐碎,堵塞主线程的同时影响异步线程的加载效率,造成卡顿甚至穿帮,比如《绝地求生》中的场景管理就是很典型的反面教材,因为开发团队的场景分块设计不合理,造成流式加载时帧数跳动明显。因此,我们在GPU Driven RP中将使用纯粹的二进制数据作为模型从异步线程,由硬盘导入,并在主线程中分帧传递到显存。这样的方法从硬件上来讲效率更高,更加友好,然而二进制数据显然不可能依靠人力制作,还需要额外的工具将美术提供的场景转换到这种场景管理方式中。
按照往常惯例,先上效果再上实现:
可以看到,帧数非常稳定,几乎没有波澜,视频刚开始的帧数波动实际上是因为Editor中强制所有资源在主线程加载,实际上打包以后,LoadAsync加载纯序列化资源是不会影响到主线程的(详见Unity Manual),从内存到显存的加载则采用了分帧的方法,这点在视频里同样表现的非常明显。
同时,Drawcall只增加了1,这说明我们依然延续使用之前GPU RP的渲染方法,将所有数据储存到同一个Compute Buffer中,这使得对显存容量和缓存更加友好,同时不会增加任何CPU端的消耗。但这也意味着之后的场景卸载会非常麻烦(文章后边会讲到)。
先说加载,加载分为三个部分:
  • 从硬盘读取资源(异步线程执行)
  • 将资源转存到数据容器中(异步线程执行)
  • 将数据信息传递到显存中(主线程执行)
从硬盘读取资源这一块虽然复杂,但是其实是最不需要说的,因为大多数项目中基本都有独立的资源管理系统,再加上Unity本身也是基于.Net Framework这种通用架构的,因此资源管理方面实际上并不是很受引擎限制。
主要麻烦的问题在于2,3条,资源转存与数据准备,我们首先开一个异步线程,该线程将长期存在,并且大多数时候依靠AutoResetEvent处于等待状态,在需要工作时从主线程启动。之前有考虑过使用Job System,然而Job System比较尴尬的是没办法使用托管堆数据,只能存储指针,如果加载的过程托管堆的数组被GC干掉,就会出现意想不到的Bug。所以这里果断自己开线程控制,并将控制脚本设置为单例,进行全局的统一控制:
v2-72a81400bd1cffcf9c4276a9dac201ad_720w.jpg 任务量并不大,因此IO不会成为性能短板,不需要使用无锁队列,这里直接进行暴力的任务缓冲即可:
v2-c27d4c175b71f1f7074e6ba5e9ab0ad8_720w.jpg 之后,在协程中读取Resources文件夹下的二进制文件,并输出为byte[],然后copy到NativeArray中并为之后的GPU传递做准备:
ClusterMeshData, Point等依然是我们之前使用过的储存Cluster信息与顶点信息,而这个pointerContainer和indicesBuffer则是之后删除会用到的,PointerContainer是一个储存有所有场景的ID的指针的List,其格式为NativeList<ulong>,而IndicesBuffer储存了当前所有Cluster在整个地图中的位置。唔。。这里看起来有点绕,我们举个例子好了:假设场景内加载了两个场景,A场景和B场景,先加载的A场景,而A场景中有3个cluster,后加载的B场景则有4个cluster,那么A场景的IndicesBuffer将会是[0, 1, 2]而B场景的IndicesBuffer将会是[3, 4, 5, 6],pointerContainer呢,则储存了[0, 1, 2]与[3, 4, 5, 6]这两个数组中的每一个数的内存地址,当然C#不能直接将指针视为非托管类型,所以我们转换为ulong格式。
那么为什么要这么做,或者说绕了这么一大圈储存这个又有什么意义呢?当然是为了删除掉场景了。StructuredBuffer在这里相当于List或std::vector,而回想起我们数据结构课上学过的内容,如何从一个队列中以O(1)的时间复杂度删除某个元素呢?那毫无疑问就是通过使该元素与最后一位元素互换,然后list.Count -= 1;
然而这样的代价就是顺序会打乱,比如[0,1,2,3]这个数组中,删除1,那么数组就会变成[0,3,2],这也就是为什么我们需要这个pointerContainer的存在。再回到刚才的示例,pointerContainer中储存了每个id的内存地址,这时候如果我们删除掉A场景,那么数组就会从[0 ,1, 2, 3, 4, 5, 6]变成[6, 5, 4, 3],因为前三个数已经和最后几位替换了,那么这时候我们就需要通过PointerContainer中储存的指针,指向6,5,4,3这四个数字,并把它们改成0,1,2,3。最后,整个游戏中就只有B场景,而B场景的IndicesBuffer则是[0,1,2,3]。
删除的逻辑代码如下:
正如之前所讲到的逻辑,我们遍历每一个数列,并使其与最后一位进行替换(如果已经在最后的几位中,就跳过),然后将结果输出到results:NativeArray<Vector2Int>中,之所以要输出这个结果,是因为要在Compute Shader中对Compute Buffer执行同样的工作,使GPU中的数据与CPU中的数据同步。这种数据同步的方法,主要是为了同时使用CPU的线性迭代,以及GPU的并行能力,并绕开了PCIE令人窒息的速度(一般从CPU通过PCIE访问GPU的IO速度只有1G/s左右以及数毫秒的延迟,可以说是慢的令人窒息了),同时该遍历过程在异步线程完成,对帧数没有实际影响,完美的实现了队列的删除。
最后,就是将NativeArray的数据复制到ComputeBuffer中了,这个过程是要堵塞整个渲染流程的,而且由于已经提到的令人窒息的IO速度,所以这个过程只能分到许多帧中执行(也就是我们视频中展示的效果)。
对于分帧的执行,当然也可以使用协程,只是我们这里为了保证每一帧只有一个任务被执行,防止出现多个场景同时加载出现异步Bug,所以自己造了一个Command Queue,还是使用大学学到的简单的数据结构方法,LinkedList,LinkedListNode的构造如下:
其中LoadCommand是封装了函数执行的类型:
之后LinkedList将LinkNode池化一下防止出现GC压力,实现比较简单不需要放上来了,值得注意的是,LoadFunction返回了一个bool值,这个bool值起到了yield return的作用,若return true,这个函数下一帧就会被Remove掉,如果return false,下一帧则还是会继续执行这个函数。
在主线程端执行的加载和卸载都是分帧异步的,在卸载过程中,我们将之前写好的储存有Vector2Int的移除步骤,传递到Compute Buffer中,并使用Compute Shader完成卸载,使GPU端的数据与CPU端同步,脚本实现如下:
Compute Shader的实现如下:
走到这里时,整个加载卸载过程的逻辑部分就全部讲完了,场景信息我们是直接储存在二进制文件中,并使用Scriptable Object作为索引,而读取,加载,卸载场景的执行过程则必须由一个单例来控制。目前我们的编程框架尚未定型,所有的场景信息(如PipelineBaesBuffer)都是储存在RenderPipeline.cs这个单例脚本中的,之后考虑到管理方便性,可能会使用另一个单例脚本单独管理场景的静态资源,并与RenderPipeline之间进行单例到单例的互动,实现解耦。
因此,整个加载过程在Enterprise Architect中用流程图表示大致是这样的:
当其他脚本(Actor)提出加载场景的命令时,开启协程并等待Unity加载完成(这一步可以换成自己项目的资源管理),并将二进制转到非托管的NativeArray中,之后分帧传递到Compute Buffer中,完成一波加载。
当Actor提出卸载命令时,在异步线程遍历整个场景,执行删除,并准备好每一部的删除的步骤,将步骤作为NativeArray<Vector2Int>传递到显存中(数量级够大也会分帧),并执行删除的Dispatch操作。
到这里,整个场景的初步流式加载过程就已经完成了,然而这时的场景还不支持材质贴图的流式加载,在之后的过程中我们将使用类似与模型加载的方法,配合Tex2dArray实现贴图加载,并实现平坦大地形常用的面片+高度图的方法,而面片的渲染方法会在开阔平坦的地形上,效率表现的比Cluster更高。



2020-12-5 20:44:31  
 赞 赞 0

使用道具 登录

0个回答,把该问题分享到群,邀请大神一起回答。

CG 游戏行业专业问题

图文教程技术文章技术文库手机游戏引擎手游引擎
显示全部 9
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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