对于最新发布的Unity 2022 LTS,开发者们最关注的技术亮点莫过于DOTS:面向数据编程的技术栈,它能辅助开发者们实现大规模仿真及超复杂场景的渲染,创建更有雄心的游戏。在Unity技术开放日北京站技术专场中,Unity中国DOTS技术主管李中元先生深入讲解了DOTS的关键功能 Entities Graphics的工作思想及设计原理。
![](https://i-blog.csdnimg.cn/blog_migrate/2cedd6ae990a284cae90178b20ff1966.png)
李中元:大家好!我是李中元,是Unity中国DOTS技术担当。
今天讲的大背景是DOTS1.0已经发布了,意味着可以把DOTS应用在项目里了。它跟之前最大的区别是它提供两年质保,当大家遇到BUG或者其他情况时,可以踊跃的把BUG提交给我们,会有专人负责修复。
今天的主题是《深入理解Entities Graphics》,这也是DOTS里非常基础的一个package,希望通过分享让大家了解它底层的运行机制。主要分三个内容:一是Entities Graphics的简介,二是它的架构设计,三是它的底层原理。
一、Entities Graphics 简介
Entities Graphics以前叫Hybrid Renderer,在DOTS1.0时它被改名成了“Entities Graphics”。为什么要改成 “Entities Graphics”?因为反映了它最基本的功能。
Hybrid Renderer有两个版本,第一个版本是随着MegaCity这个Demo发布的,当时DOTS还在比较前期的阶段,它需要做渲染方面的支持,所以Hybrid Renderer V1是为了MegaCity demo定制的一个版本;后来V2版本DOTS Graphics team把这个package完全重写掉了。Hybrid Renderer反映了它的本质,它不是“pure” DOTS renderer,它依然使用了像Mesh、Material、Shader这些我们传统Unity引擎里的数据结构,所以它是一个混合的模式。
![](https://i-blog.csdnimg.cn/blog_migrate/5779a23d3261d685abe8ace2421b6f9c.png)
这张图是最典型的使用SRP时在profiler见到的一个场景。当我们进行渲染时能看到这种大量绿色的标签,这些绿色的代表一些跟渲染相关的调用。当渲染压力比较重时,它们会随着渲染压力逐渐变长。这里面绿色的东西主要做两件事情,第一,把显卡需要的数据上传到GPU上,这样GPU才能访问它们;第二,组织batch,这些东西在显卡上怎么显示,需要在这里把它组织好。
这里面有明显的问题,我用红框标起来了,就是上面不管多忙,底下都一直在看戏,只有前面的一点在裁剪时有一点点工作,后面全程看戏。这种设计在当前的手机6核、8核,PC10核、20的大环境下,已经有点不合时宜了。
![](https://i-blog.csdnimg.cn/blog_migrate/bd6e2d0ab8035bcd651e9ab24962f359.png)
SRP和Hybrid Renderer V1的另外一个问题是在主线程组织Batch,然后每帧都要上传渲染状态。这里面还隐藏了一个问题,在多相机时这个问题会变得更严重。Hybrid Renderer V1在当时是定制的一个渲染管线,所以它会break掉一些东西,比如这个instancing关键字被break掉了,Shader Graph和材质的编辑也是特殊的流程。当时的Hybrid Renderer V1只支持了在HDRP在Megacity用到的这些feature,其他feature都是不能用的。
![](https://i-blog.csdnimg.cn/blog_migrate/6b7fc4ea47acecb960df030bb9be4f9e.png)
所以Entities Graphics package在这个大环境下,它提出了两个目标。一个是它要做GPU data persistent model,GPU数据只上传一次,这个只上传一次不是每帧上传一次,是全局只上传一次;第二是不需要每帧都组织Batch。刚才绿条里做的两个非常重的事情,都要把它变成一次性的,而不是每帧都要做的。这样在渲染时那些绿色的渲染任务会极大减少。
为了做到这个GPU persistent data model,就需要在ECS访问Shader需要的数据,相当于把CPU上的数据通过某种方式映射到GPU上去。因为我们有了GPU persistent data Model,这时候如果我们所渲染的数据发生了增加或者删除,怎么办?我们总不能把数据全部清空掉,然后重新再组织一遍、再上传一次,所以就有了Delta Update。Delta Update本身就是一个效率非常高的方式,什么东西发生了变化,只修改那部分,其他没有修改的部分不需要动,这个效率会有非常大的提高。第二个是Persistent Batches,Batches是说场景中物体发生添加/删除的时候,不需要大家手动管理里面的Batches,它是自动反映到Batches上来,所以使用上来讲大家并不需要关心Entity Graphics这个 package是怎么运作的,只需要添加/删除Entity就可以了。
![](https://i-blog.csdnimg.cn/blog_migrate/be7dcc1af7b2c35f889712bd3a21ff06.png)
Entities Graphics架构设计
我们来看下传统的基于GameObject渲染方式的架构是什么样的。
左上角这部分是SRP的前端,就是我们经常能够接触到的HDRP、URP或者自己定义的管线。这部分是在组织pass,渲染透明物体,渲染不透明物体或者渲染深度、阴影各方面,组织这些东西。组织完之后,右上角可以理解为是Mesh renderer,场景中要渲染的物体。Mesh renderer一定有个transform,有了transform就会有各种变换矩阵,还有我们自定义的一些per object属性,比如Material Property Block。左下角Assets是渲染需要的材质、Shader、Mesh或者贴图这些数据。大家能操作的也就是外围的这三个大部分。
再往下对大家来讲是一个黑盒,引擎内部会自动去做LOD选择、去做Culling,再去把这些数据组织成引擎需要的内部格式,再送到SRP Batcher。再往下是GfxDevice,就是跟各个平台对接的图形API,然后我们的东西就渲染出来了。这是传统的基于GO的渲染框架。
![](https://i-blog.csdnimg.cn/blog_migrate/2392dd1fe522fc4189573f73f44fecf8.png)
Entities Graphics是什么样?左上角没有发生任何变化,左下角没有发生任何变化,右上角变成了ECS里的Component Data,就是我们的数据。Hybrid Renderer通过ECS里面的system,去query里面的Component Data,然后在Hybrid Renderer里执行Burst优化过的代码,Burst优化过的LOD选择、Culling、Job System。第一是Burst优化过的,第二是多线程的,然后把数据组织起来,通过另外一个回调接口,组织Batches,再把这些数据送到SRP Batcher,所以它只是右边发生了变化。为什么它能够同时支持HDRP、URP?这也是原因所在。Hybrid Renderer虽然叫 Renderer,实际上它不是一个Renderer。
![](https://i-blog.csdnimg.cn/blog_migrate/a79a37f38b56410a8e2607ba53ce8dd0.png)
我们再来看一下,这是SRP Batcher文档里的一个图。C++的这部分是原来我们在使用SRP管线时藏在引擎内部的,它主要是做右边的Per Object Large buffer。Per Object Large buffer里面包含了Per Object的一些属性 ,比如需要每个Object有不同颜色等,它会组织在这里面。在Entities Graphics里这部分会被替换成高性能的C#代码,这部分代码再通过Batch Renderer Group的API把它送到 Per Object large buffer里,就相当于Batch Renderer Group会帮你组织这个Per Object large buffer,底下绑上材质的属性,绑定到shader上,shader就可以执行了。
![](https://i-blog.csdnimg.cn/blog_migrate/998b9d25b6fe7c9ecd20afb633bf64db.png)
我们能够看出来, Entities Graphics其实不是一个新的 Renderer,它只是从DOTS把数据送到GPU上的一个path,而且是比传统基于C++代码更快的data path。这里隐藏的一点是它需要SRP Batcher,所以大家在使用Entities Graphics时一定要使用UPR、HDRP这种基于SRP的渲染管线,如果基于默认管线,会发现场景里什么都画不出来。
![](https://i-blog.csdnimg.cn/blog_migrate/587278354944d705c4292488baaf8e2b.png)
接下来看看Entities Graphics的架构设计,它主要包含两部分内容。一个是Data Persistent Model,一个是Persistent Batches。先看Data Persistent Model,绿色的部分主要包含两部分内容,一个是数据的上传,一个是组织Batch。
![](https://i-blog.csdnimg.cn/blog_migrate/ec32a42c6493c17331fbdcdf34654be9.png)
它里面是怎么工作呢?我这里有张图。这个Object Data是Mesh Renderer转换成的一个引擎内部结构,当我们准备好场景里所有物体时,它会转换成这样一个队列或者数组。它会首先看第一个Object Data是不是SRP Batcher兼容的,如果是的话就继续找第二个、第三个,直到找到第四个不是SRP Batcher兼容的,前三个是一批,送到SRP Batcher里;后面一个不是SRP Batcher兼容的,再后面一个也不是,它们会被送到默认管线里去做;然后再看下一个,以此类推。
这个过程首先意味着Batch是动态组织的。如果场景里这两个紫色的Object Data被我删除掉了,有可能后面一个Object Data能够和前三个组成一个Batch,然后送到SRP Batcher里去。还有一个问题,本来前三个Object Data能够和第四个Object Data合批,但是由于中间这两个紫色的存在,它不能合批了,导致我们多了一个批次,这是SRP Batcher里一些大家能看到的小问题。
![](https://i-blog.csdnimg.cn/blog_migrate/56ad1636aaae5e5b07f43a282d67df13.png)
左边是不支持SRP Batcher时的工作流程,明显左边更长,右边更短。其实右边还隐藏一个东西,就是红框里的这个Bind with offset Object data from a large CBUFFER,这个large CBUFFER在哪?在这个图里没有显示出来,所以即使使用了SRP Batcher,依然要组织一个大的buffer,而且这个大的buffer是每帧去组织的。
![](https://i-blog.csdnimg.cn/blog_migrate/7e495dacb4ab99244e5af68538a9cd27.png)
所以在Data Persistent Model里,第一件事情做的就是把这个上传buffer的过程变成并行的,由一个线程去做变成多个线程去做,而且这个多线程执行的是由burst compiler优化过的代码。所以就不存在主线程或者Renderer去上传GPU data的事情了,这部分时间就被省下来了。
还有一个非常大的好处,大家在使用SRP的时候,会默认让大家尽量少的使用相机,因为每个相机都会触发刚才的完整渲染流程,相当于数据会重复上传N遍,但是在 Entities Graphics里,这个数据永远只会上传一次,所以这是非常棒的,在 Entities Graphics里大家添加相机时的负担就没有之前那么大了。
![](https://i-blog.csdnimg.cn/blog_migrate/cf163b8a27f521cdd2a3154ca4d195d1.png)
这是我截的一张被Burst优化过的上传的图,发现效率非常高,而且可以用满所有的核心。这个图比较早,颜色还是蓝色的,现在新版本被burst编译过的job已经是绿色,不再是蓝色了。
![](https://i-blog.csdnimg.cn/blog_migrate/1fdb727702b64b0bbcdf22202e61f416.png)
还有一个事情是delta update,因为场景里物体不是静态的,而是动态变化的,每时每刻都有物体增加/删除,这时候我们需要做到增量更新。怎么做delta update呢?它主要利用ECS机制,ECS里可以检测每个Chunk数据是否有变化。
![](https://i-blog.csdnimg.cn/blog_migrate/ae01c093a3171afc89e20c61dbda5156.png)
我给大家看下这张图,上面是ECS里的数据,Chunk1、Chunk2、Chunk3,每个Chunk里components的数量不一样,component的数量越多,这个条越短。为什么条会越短?因为ECS里的chunk是16K固定大小的,如果一个Chunk里放的数据种类越多,每个数据种类平均分配到的空间越小,像右边的Chunk只有2个数据条,所以它是最长的,左边这个是中等的,中间这个是最短的。
然后我们会遍历每个Chunk,检查这个Chunk有没有变化。Chunk1里橘色部分发生了变化,我把它记下来,需要把它上传到GPU。然后我们再看Chunk2,Chunk2是橘色、绿色发生变化了,说明这两个数据都要上传。Chunk3没有发生变化,就不去规划它的上传。上传的时候发现只会上传Chunk1橘色的部分和Chunk2的橘色、绿色部分,剩下的不需要上传,这样效率得到极大提升。
![](https://i-blog.csdnimg.cn/blog_migrate/578025e9da2b67bb99c77c9ee3cfccc3.png)
接下来看看Persistent Batches。Persistent Batches也是非常重要的,因为组织Batches是一个很有技术含量的事情。为什么要做Persistent Batches呢?因为不需要每帧上传,组织Batches的时间就省掉了。这里面还隐含了另外一个好处,就是手动组织的Batch比自动组织Batch的那种方式要好。刚才跟大家展示过了,橘色那三个Object Data如果中间插入紫色的,就会导致后面橘色的Object data不能跟前面的进行合批;如果手动组织的话可以把这个情况避免掉,就可以得到完美Batches的情况。
所以同样一个场景,如果大家用 Entities Graphics去渲染,和用纯粹的URP/HDRP渲染,你会发现在profiler里面它的Batches数量明显比之前的URP/HDRP要少。我在Viking Village上做过测试,大家也可以下载package,把它里面的基础资源拖到subscene里面,然后再按play,就可以直接看到你想要的结果。你会发现拖到subscene之前和之后,它的渲染数据有非常大变化。
![](https://i-blog.csdnimg.cn/blog_migrate/c5afb385258c693665b821e982585737.png)
Persistent Batches还有一个好处是我们可以提供一个DisableRendering tag,当我们有些数据不想渲染时,可以把这个tag加上。
拿MegaCity举例子,MegaCity里有大量建筑物,假设一个建筑物里有200个Mesh renderer,传统的情况下做culling的时候会把这个建筑物里的200个Mesh renderer分别和相机frustum做一次求交,看它是不是需要被裁剪。但是在 Entities Graphics,完全可以给大建筑物一个bounding box,然后求交的时候先按建筑物进行求交。如果整个建筑物都不在场景里,是不需要再进一步在里面进行求交运算的,这样省了大量CPU,culling的效率就会上去了。当我们发现求交不成功的时候,就可以整个建筑物挂上一个DisableRendering tag,它就不会参与后面的渲染了,整个操作就会变得非常方便。
![](https://i-blog.csdnimg.cn/blog_migrate/0204247d8a16d2fa0805d56446d7e8c4.png)
我们看一下Batch在GPU里长什么样子。
首先,这里有两个Batch,Batch1和Batch2。Batch1里红色的3份,绿色的3份,这代表一个Batch里有3个Chunk,每个Chunk里component数量不一样,所以长度就不一样。最长的红色里面会发现有块黑的,是因为我们创建Batch、计算Batch大小时是按照Chunk大小算的,比如这个Batch里有3个Chunk,红色数据加起来总共有多少,就预先分配这么大GPU的buffer,不满就会留空。当红色数据发生变化时,不影响其他的数据,因为它们是被预先分配好的。Chunk数据变化只影响长的红色的部分,而不影响其他的部分,这是非常高效的设计。
当然,代价是浪费了一点点空间,所以这个Batch的利用率或者Chunk利用率是需要大家去关心的事情,Chunk利用率如果不高,就浪费了内存和显存。
![](https://i-blog.csdnimg.cn/blog_migrate/f9d6bc279765afc6ad8ad50835ad8385.png)
做完这两点之后,我们对比一下Performance。对比Performance之前要搭建一个场景,相同的场景下才能对比性能变化。这个场景是10万个动态物体,每帧去改变物体的位置和颜色,这里写到它只有一个Shader和一个Mesh。
![](https://i-blog.csdnimg.cn/blog_migrate/d57aa898cf1a96aa9d389436dbaff279.png)
最终的结果是差不多在high end laptop上比URP/HDRP快5倍左右,这是非常惊人的数字;在移动平台上,它差不多快30%,这时候大部分是GPU Bound,GPU限制了Entity Graphics的发挥,CPU的时间比URP省2-3倍,GPU Bound显示不出这个优势。
![](https://i-blog.csdnimg.cn/blog_migrate/3aa7509a79f34810114d99cad15cb8ec.png)
这是他们做的截图。V1右边绿色部分巨长无比,差不多是17毫秒;V2就发现短了非常多,因为非常多工作被off load掉了,只需要创建一次,剩下的不需要再改了。这个大概是3毫秒左右,省了非常多时间。
![](https://i-blog.csdnimg.cn/blog_migrate/69d9e8e146b227558624d7501e096e1a.png)
![](https://i-blog.csdnimg.cn/blog_migrate/2de84e541d06f4be0b0c6e7f43791fbd.png)
接着看Entities Graphics底层用了什么API。为什么讲API?因为很多人觉得暂时用不了DOTS,但是又需要渲染这么多物体,那Entities Graphics用哪个API,我们就用哪个API,它能做到的事情我们就都能做到,反正它是开源的。
它底下用的是Batch Renderer Group的API,简约并不简单。我今年给大家分享过一次这个API,大家在B站上应该能找到这个视频。Batch Renderer Group在2022.1时被重写了,在2022.2.2加上了OpenGL ES3的支持,移动平台支持能力得到了提高,在平台适配性上也做得非常不错。当然,后面这个API有可能会再引入一些更好的迭代。
![](https://i-blog.csdnimg.cn/blog_migrate/7c15434b2ba1fae86c3555767ddc0174.png)
先来看一下这个API长什么样子。你有一个Batch Renderer Group,然后给它一个回调函数,然后注册上这个东西需要的Mesh和Material,这个API就差不多完成三分之一了。
![](https://i-blog.csdnimg.cn/blog_migrate/1084825e9516b25890b0918d53ef9033.png)
第二个API是AddBatch,AddBatch这个设计天生是为了Persistent Batch准备的,用了这个API就是用了Persistent Batch,你的Batch就是你自组织的。上面这些是需要给Shader上传的属性,有了这些东西,Shader就能够显示了。
![](https://i-blog.csdnimg.cn/blog_migrate/8d49a68a350362d858666747844c3b73.png)
这是第三部分,就是OnPerformCulling这个接口。这个接口下,大家有机会可以自己处理Culling问题。相当于大家把自己当成一个引擎程序就好了,处理引擎里每个相机的裁剪,裁剪之后怎么组织自己的渲染数据,这里的API给了非常大的灵活度,这里面写的其实并不容易。
![](https://i-blog.csdnimg.cn/blog_migrate/4bb22a35e60ad5e068f3e503b9edf287.png)
这是官方的测试场景,横竖一个正方形,里面摆各种各样的mesh,它们的材质有点不一样。官方数据给的上面是URP 10.4毫秒,下面用了Batch Renderer Group API是0.8毫秒,大概提高了20倍的性能。
![](https://i-blog.csdnimg.cn/blog_migrate/023021964cc93bab5791f4fade235ec6.png)
![](https://i-blog.csdnimg.cn/blog_migrate/879a43eb856420875a8dddd8c2bc2381.png)
这是我自己做的demo,驱动10万个物体。大家看到这里所有的颜色、所有的东西,每个都是一个Mesh,动态生成的每个颜色都不一样,它里面的材质也是好几种材质随机组成的,所以这是一个更贴近于现实场景的测试。最终的测试场景在Macbook上大概能跑250帧,是非常惊人的一个效果。整个CPU的组织只有1毫秒,10万个物体只花了1点多毫秒,妥妥的GPU Bound,CPU在等GPU,GPU已经被压得不行了,如果GPU更好的话,可以得到比这更好的效果。
![](https://i-blog.csdnimg.cn/blog_migrate/f23911689dfa16f4e868f2edcccd53d7.png)
我把这个工程开源出来了,大家可以去我的Github上下载。官方的Doc写得比较好,论坛上也有最基础的使用方式。如果大家用不了DOTS,又需要高效绘制的话,可以尝试用Batch Renderer Group,但是这个接口使用难度比较高。
Batch Renderer Group
最后给大家对比一下,同样渲染大量物体,它跟DrawMeshInstanced有什么不同?
首先,DrawMeshInstanced只能画一个Mesh,然后还要每帧上传所有物体的matrices,还要为它书写这些自定义shaders,因为DrawMeshInstanced的shader是单独书写的,然后一个Mesh、一个材质就会调一次DrawMeshInstanced。但是在Batch Renderer Group里不需要这样做,你的数据只需上传一次,它是天然支持URP和HDRP的Lit,可以支持多个材质跟Mesh,你还可以自定义Culling。DrawMeshInstanced是不能给大家做Culling的,你提交多少,即使在相机的视口之外它也是需要渲染的。所以BRG给大家提供了非常灵活的一些能力,可以做非常多灵活的事情。
![](https://i-blog.csdnimg.cn/blog_migrate/16bdd26d2dc7df214b1bb4ec6bd70ec5.png)
我今天的分享就是这些,谢谢大家。