卡达什圣杖6月8日,Unity技术开放日(Unity Open Day)正式在北京举办,会上来自Unity的技术专家、完美世界《诛仙手游》客户端负责人、《放置少女》技术专家、FunPlus 引擎技术负责人等嘉宾,将带来关于游戏管线渲染,性能优化、技术落地等实用技术分享。
在活动中,Unity中国引擎底层架构技术主管赵亮以“Unity小游戏开发简介”为题进行了精彩的分享。
小游戏是一种嵌在宿主应用内部,无需下载安装,可以即点即玩的游戏产品形式。在国内小游戏用户的规模庞大,在移动游戏市场里是一个不小的占比,大约占了20%左右,因此Unity也在逐步加大对于移动端小游戏的支持。
首先,我们介绍一下当前主流的小游戏平台,以及他们采用的技术方案。接下来介绍一下即点即玩小游戏需要用到的资源流式加载,然后,我们分别介绍两种小游戏技术方案,一种是Native Instant Game,另一种是WebGL,最后我们介绍一下我们未来的工作方向。
目前,我们国内有很多小游戏平台,有微信、QQ、抖音、头条、快手,还有Oppo、Vivo、华为、荣耀、支付宝、淘宝、百度、233乐园,在这里我们简单介绍一下比较具有代表性的两个平台:微信、抖音以及他们采用的技术方案。
微信是从2017年12月开始发布一个叫《跳一跳》的小游戏然后开始爆发,这里有一些公开的数据。2019年1月起开发者数量就已经超过10万,2019年5月份的时候用户过亿,在2021年的时候,据说流水过千万的产品已经超过了50款。
这张图上面展示了微信小游戏的品类,随着时间的发展趋势,我们可以看出,小游戏品类近年逐渐从超休闲向中重度发展,例如像MMO、策略类的游戏也开始往小游戏平台上来。
大家可以关注一下微信的公开课,上面有一些开发者分享了使用Unity去开发小游戏的经验。在上一季有《我叫MT2》的分享,这一季还会有《小小蚁国》的案例分享。
在抖音的平台上面,巨量算数调研发现,小游戏的受欢迎程度仅次于App游戏,同时小游戏用户规模较大,特征也比较显著,大多是这种18-40岁的年轻男性比较多,消费能力还是可以的。
然后在宿主应用中去实现一个即点即玩的小游戏,目前主要有两种技术方案,一种是基于浏览器的内核,使用Web Assembly再加上WebGL的方案,另一种是在安卓上实现的这个Native Instant Game。
WebGL方案的同时支持iOS、安卓两个操作系统,大部分的小游戏平台都采用了这个方案。Native Instant Game仅仅能支持安卓系统,不过它有它的好处,它的好处是游戏的品质可以媲美原生App,目前抖音、头条、快手已经集成了这个方案,支付宝也在集成的过程中。
前面提到了小游戏从超休闲往中重度不断发展,小游戏中用到的资产的越来越多,有一些小游戏资产打包之后有几百兆,甚至1G以上,所以说小游戏其实越来越不小了。为了让玩家可以即点即玩,减少等待下载的时间,需要对这个资源进行按需的、流式的下载跟加载。
对于一个游戏开发者来说,管理好这个资源的流式加载,还是需要投入不少的开发时间。因此,我们在引擎侧开发了这个Autostreaming的功能,让引擎底层自动的去处理好这个流式加载。
这里我们简单介绍一下这个Autostreaming的自动流式加载的工作原理。这个Editor里面,我们提供了工具,可以在打包的时候自动分离出重度资源,例如像Texture、Mash、Audio、Animation等,这些资源会被部署到云上面去。在分离出重度资源之后,游戏的首包A/B包会大大减小,因此,可以让小游戏快速的下载、加载。
在游戏运行的时候,引擎会根据需要自动从云上去下载资源,开发者不用修改游戏的逻辑,可以像往常一样同步的去Instant一个profile。然后,这些Texture、Mash会在一个后台队列里自动的被下载跟加载。
在这里,我们以一款线上的WebGL小游戏为案例,看一看这个Autostreaming的效果。首先,我们可以看到首包中的数据减少了很多,从42M降到了6.8M,因此大大减少了启动的耗时,从40秒降到了不到八秒,用户打的A/B也减小了一些。
在这个案例里面,因为我们只选择了一部分贴图做Autostreaming。所以瘦身的程度还不是很大,另一处很重要的收益来自于内存,内存占用减少了75M,这个内存对于WebGL在iOS平台上是很珍贵的,后面我们会详细的聊这一点。减小的原因,主要是被剥离的重度资源有的更加合理的生命周期,没有开启Autostreaming,这些纹理、加载后等依然会占用一部分内存。
例如首包的内存,还有A/B没有被Unload之后的内存,这是WebGL平台一个比较特殊的地方,它没有一个真正的文件系统,只有一个内存中的文件系统,所以说一个文件进来之后,就得先放到内存里面去,跟我们在原生的App上不一样,原生的App这个文件还是一个文件,你在读的时候只是每次会读取一块,我们只要用一个内存缓存放到一个窗口就可以去访问这个文件。
接下来我们先简单介绍一下Native Instant Game的一个方案。它的优点很明显,它可以直接对标原生的App,跟原生的App性能是一样的,体验也是一样的,支持多线也支持Vulkan。原声App用的插件它也都可以用,它可以采用同步的方式去访问沙盒中的文件,访问的效率比较高,同时占的内存也比较少,它以一个独立的紫禁城运行在沙河之中,不会干扰宿主的运行,引入一些稳定性或者是安全的问题。
对于Native Instant Game,我们提供了完整的提审、发布、更新方案,所以对于移动游戏开发者来说,适配Native Instant Game的成本很低,只要做好资源的流式加载,不需要再做一些额外的适配与性能优化。
这里有一张截屏,来自抖音平台上面的一个小游戏叫做《古董就是玩》,它的材质品质比较高,我们曾经也尝试将这款游戏配套到WebGL平台,那个画质的降低还是比较明显的。所以说,这也就是Native Instant Game的一个优点。
当然,讲了这么多优点,它的缺点也比较明显,就是他目前没办法支持iOS这个平台,iOS平台还是很重要的,所以微信没有集成这个方案,字节和快手这些平台选择另一个策略,就是两个方案都支持,在iOS上使用WebGL的方案,在安卓上,既支持WebGL,也支持Native Instant Game。
所以有些游戏想追求性能天花板更高的话,就可以Native Instant Game,假如它是追求受众更多,我的游戏品质还没达到这个性能天花板的上限,那我可以选用WebGL的一个方案去做。
这张图上面我们简单介绍一下Native Instant Game的工作原理,我们首先把每个小游戏都要运用到的这种运行实库、默认资源打包在一起,作为一个共享的引擎包,方便这个宿主App提前准备好,从而减少每个小游戏启动时候等待下载的时间。这个共享的引擎包大约有9Mb,具体包含了这些东西,有运行时库运例如像Unity.so、Mono.os这种东西,还有一些通用的.Nel的dll、System.dll、Unity Engine.dll这种东西,然后还有Unity default resource.
有了这个共享的引擎包之后,开发者对小游戏平台进行打包的时候,这个游戏基本上打包成两部分就可以了。首先是一个很小的首包,基本上在5~10M左右,可以方便小游戏快速的下载跟加载。它里面主要包含这个游戏本身的逻辑,以及一些第三方的SO插件。
另一部分就是游戏启动之后的流式加载资源,当用户使用前面提到的Autostreaming工具,Unity Editor会自动拆分出这些比较重度的资源部署到这个UOS上面去,UOS今天上午我们同事应该介绍过。
同时,我们会生成一个描述文件,里面包含了游戏的名称,首包的UAL地址、引擎的UAL地址,以及这些文件的Jersey信息。这个信息可以提供给宿主的客户端加载一个小游戏的时候去使用,也可以供这个开发者去向平台去提审,他只要提审这个Jersey文件就好了,然后平台可以通过这个Jersey文件知道你是哪一个游戏。
当宿主的客户端选择去启动一个小游戏的时候,他会根据刚刚提到的Jersey文件里面的描述,下载到游戏的首包,然后还有前面提到的这个共享的引擎包,宿主通常都会提前下载解压准备好。然后,客户端把这个首包解压到小游戏对应的这个文件夹里面去,然后通过一个很小的Instant Game Launching启动这个小游戏就好了。
所以说,宿主的App它集成了这个Native Instant Game的能力,对宿主APK增加其实是很小的,因为这个Instant Game Launch本身并不大,逻辑不复杂。然后当unity小游戏运行的时候,他会根据自己的需要,自动的从云端去下载这些所需的资源。这个是针对Autostreaming的情况,假如不是Autostreaming,那用户就要需要自己去下载。
接下来我们就更为详细的介绍一下WebGL的方案,它的方案优点很明显,支持安卓也支持iOS,但是WebGL方案限制很多,在这里我们会用更多的篇幅来介绍WebGL。
首先,我们来看一看平台的特点。这个WebGL在iOS平台上的时候,内存十分受限,低档机不能超过1G的内存,高档机上限大概在1.4G左右,一旦超过这个限制很可能就会触发操作系统的Out for memory然后迫使这个进程重启。从CPU这边的性能来看的话,WebGL的运行效率比原生的app要慢三倍左右,它目前只支持单线程,不支持多线程,所以WebGL小游戏的CPU性能比原生的app要低不少。
在图形API上面,它只支持WebGL1和WebGL2,所以说有一些高级特性跟优化没办法使用,例如computer shader这种。刚刚前面也提到,它没有文件系统,所以需要更大的内存去模拟文件系统,这也导致Unitycrash机制受到很大影响,crash文件无法被同步访问。
由于这个CPU测性能比较弱,在iOS上当这个游戏复杂度提高,计算量增大的时候,手机很容易过热。对于网络API它也有限制,它只支持websocket,所以说这一点需要开发者进行适配,因为以上这几种限制,导致它能使用的插件也比较有限。还有一点,iOS对WebGL平台的支持也不尽如人意,我们经常需要为iOS平台做一些特殊的优化,写一些特别的workround。
总的来说,对于WebGL方案的话,iOS平台的问题比安卓平台的问题要更多一些,因此在接下来我们更多的都关注在iOS平台上,如何去profile与优化小游戏,只要iOS平台优化好了,之后安卓平台基本上问题不大。
这里我们就使用一个案例,分别打包成一个原生的app跟一个WebGL的小游戏来去对比内存、CPU、GPU的差异,我们测试使用的手机是一个iphone12。我们先看一下内存,左边是原生App,右边是iOS上WebGL小游戏,我们可以看到总的内存占用多了450M左右,然后增大的地方一个是WASM的加载与编译,占到了370M,然后还有WASM heap里面有一些Unallocated的地方多出来90M,然后File System多了60M。
WASM的加载与编译主要是因为除了WASM文件本身之外,浏览器的内核在代码编译执行的时候也会产生更多的内存消耗,相关的缓存、GIT的优化都会使用比较多的内存。假如一个WASM是30M,它加载进来之后可能会有300M,涨了十倍左右。
然后多出来的是WASM heap中间的Unallocated部分,WASM heap的大小,它是从一个预设值开始,然后以一定的步长去逐步扩容,但这个扩容的方式比较傻,它需要复制整个areabuffer,所以说在它扩容的时候,会产生一个很高的内存峰值。假如我们从400M扩容到500M,意味着在扩容的时候,400M也在500M也在,总共会有一个900M的峰值。
所以说我们在这里就建议开发者,根据这个游戏实际的内存峰值,在开始的时候,设一个比较大的预设值,但这样会带来另一个问题,就是它通常在尾部会有一段尚未分配的地方,也就是我们看到的这90M的地方。然后是文件系统会多使用内存,这个是刚刚提到的,浏览器的一个沙盒机制,导致这个WebGL无法访问本地的文件,它为了浏览器的安全,只能就是说使用javascript加inndexDB去模拟文件系统。WASM去访问javascript、javascript再去访问inndexDB,在这里的话就是说,它不能够像Native的文件系统那样直接使用一小块内存,然后逐渐的去访问一个大的文件。
还有一处值得我们注意的地方,就是mono heap和Emspripten malloc分配的这个空闲空间,在WebGL上面mono heap是由Auto CPP去分配管理,其他的native内存包含引擎的native heap或者第三方库都是由Emspripten malloc来分配管理,这两个讨厌的地方是,它们都是只增不减而且相互独立,空闲的空间无法共享,所以说我们要注意控制各自的峰值,谁的峰值高了都不行,因为他高了之后,他降下来之后空余出来的内存,假如mono heap空出来了之后,Emspripten那边就用不到。
最后,我们看到WebGL这边相比原生的app也有一部分地方内存占用有减少。比较显著的是这个native heap的IL2Cpp Runtime,从101M降到了35.3M。这里主要是因为我们针对WebGL平台做了优化,后面会比较详细的介绍这一块的东西。Asset相关的部分也有降低,因为我们资源压缩格式进行了调整,还有一些差异就来自于引擎底层的内存分配器,在不同的平台上面行为跟策略有些不一样。
看完内存之后,我们先来看一下CPU。从之前在网上面看别人的Benchmark研究就是说webassembly的执行效率大约是原生app的三分之一左右,我们也拿了一款真实的小游戏做了测试,在iphone12上测的。
从这个timeline profile我们可以看出来,原生App每帧耗时大概是在3.5毫秒左右,WebGL小游戏耗时需要到十个毫秒,所以总体看来,WebGL小游戏的CPU性能跟原生app相比确实是差了三倍,印证了之前Benchmark的结果。
再往后我们看一下GPU的对比,这边的话,去除这个空白网页本身的GPU消耗,对于一个游戏,WebGL跟原生app的GPU性能差距其实不算大,相比较CPU跟内存,GPU这一块我们可以假定认为WebGL小游戏的GPU能力跟原生app基本差不多。
然后简单介绍一下,WebGL小游戏的开发或者是移植,近几年已经有大量的成功案例,所以说新进来的开发者不用很担心,这边之前已经有很多大家的经验在里面,或者之前踩过的坑都已经处理好了。我们Unity这边有一个官方的qq群,大家有兴趣的话可以加进来。微信他们因为支持WebGL小游戏也要支持Unity,所以他们也整理了很详尽的教程,前面提到,他们公开课上也有一些开发者分享了一些使用unity开发小游戏的一些经验。
为了减轻WebGL平台这些限制对小游戏开发的影响的,我们在引擎侧也做了很多的优化跟改进。我们优化了内存的占用、绘制的效率、尝试进一步给引擎代码瘦身,我们也在不断的优化小游戏的启动速度。
接下来我们来看一下在内存方面我们做过的优化工作,主要一个是IL2CPP的运行时内存占用,我们这块优化的比较多,一个案例就是我们从64M一直降到了33M,然后我们还优化了DynamicVBO pool的复用机制,可以减少粒子系统这些内存占用,我们这边测试的一个案例的是从59M降到了38M。后面提到了一些代码轻量化或者是资源裁剪也会帮助减少运行时的内存占用。
这里我们来详细介绍一下IL2CPP运行时的内存优化。首先,我们来分析一下IL2CPP运行时主要的内存开销,从这个表格里面我们可以看出来,它主要是三块内容,一个是Metadata,这个是我们在运行时构建的元数据结构,里面主要是一些IL2CPPClass还有它的一些成员变量,第二个是global-metadata.dat,这个是我们打包时生成的元数据序列化文件,在WebGL平台上面会被完整的下载到内存中间来,再然后是一个哈希表,这个哈希表是用来加速元数据访问。
目前,我们优化主要是针对Metadata部分,这里采用的方式是针对IL2CPP的Class,还有它的一些成员变量进行延迟加载。这个成员变量里面主要是Methodinfo占比最大,在IL2CPP之前的实现当中,在使用到某个类型的时候,我们就把这个类型完全的给初始化了,包括它所有的函数、接口、事件、属性、虚表这些东西,但是后来分析发现,脚本代码在运行中间通常只会用到很少的一部分原数据,只有在一些反射或者是虚函数调用的时候才会访问。
这里有一个例子,我们有一个数组类型,它有155个方法,25个虚函数,实现了六个接口,但实际运行中间,只会用到其中很小的一部分,所以说我们的优化思路就是,去延迟加载这些元数据,真正等到使用的时候才会去初始化、分配内存。
总之,右边的图我们可以看出来,这里除了Fields之外的其他信息都可以进行延迟加载。Fields信息就是说,在构建这个对象实例的时候就需要,因为他决定了这个对象实例的内存布局。我们这里延迟加载的力度基本上就可以精确到诸个方法。延迟加载也会带来一个比较小的开销,就是说它访问之前需要做一次非空的判断,我们目前还没有profile出来这会带来一些性能回退。
在这里,我们拿两个实际案例来对比一下优化前和优化后的内存占用,可以看到,第一个案例是从63.8降到了33M,第二个是从11M降到了6.5M。除了我们刚刚提到的Metadata内存降低之外,我们可以看到就是说这个Hashtable也降了。
除了上面说的内存优化之外,我们在绘制这边也做了很多优化工作,在WebGL2上面,我们刚刚提到它不支持computer shader,所以说GPU skinning不能使用,我们在这里通过transform feedback支持了GPU Skinning,在我们的测试案例中把这个帧率从15帧提高到了24帧。
然后我们还优化了Shader Compiler,可以把non-const global的变量移到这个函数集中间。很奇怪的是这样移过来之后,我们的帧率可以从23帧提到了55帧。还有这种像Immediate Const Buffer的转换,我们把它实名成const,并且赋予一个初始值,然后就可以把这个帧率从32fps升到37fps,这都是一些实际工作过程中发现的这些细节的地方。
这里面我们还提供了一个配置的max vislble light值,之前的默认值设成32,但32这个值太大了,把它改成16甚至更小的值之后,性能会有很大的提升。
前面还说到了iOS平台对WebGL的支持不够好,所以说针对iOS平台我们也做了一些特殊的walkround,优化了urpbatch在iOS上的行为,避免使用过多uniform变量,否则iOS设备上的性能会急剧下降,然后在iOS14.x-15.4版本上有一个bug,所以说针对这些版本我们对于同一个canvas不去共享IB和VB,这样可以改善UI这边的渲染性能。
这里介绍一下我们在WebGL2上面通过这个transform feedback来实现的GPU skinning,在这里我们可以看到,打开了GPU skinning后每一帧的耗时,大家可以看到上面的数字,左边是每帧的耗时,中间是近期它耗时的最小值,右边是近期耗时的最大值,我们只关注左边这个数字就好了。左边是开启了transform feedback的GPU skinning之后,平均每帧大概在42毫秒,假如没有开启它,每帧需要消耗大概67毫秒。
接下来我们来看一下引擎代码的轻量化,从前面的内存分析我们知道30M的一个WASM加载的时候会占300多兆的内存,所以说我们希望生成的WASM越小越好。之前Unity裁剪的方法有这种Managed Code Strip、Engine Code Strip,它们是通过静态分析依赖的方式去做strip,然后以函数为颗粒度,我们在这里会更加深入的去分析,我们生成的WASM代码,看看除了这两个Strip之外,是不是还有更多的优化空间。
这里我们分析了两个案例,他们游戏的WASM指令数大概都是在1200万左右。可以看得出来都IL2CPP在里面占了大头达到60%,引擎的c++代码大概占了40%左右。我们按照模块进行排序,可以看到其中比较重的模块有physx、Particle System、Sqlite、Mecanim这些。
目前通过分析发现的问题有像这个案例二,它是一个消除类的游戏,没有使用到很多physx的仿真,仅仅只是在UI上使用了physx的射线检测,然后又因为这个原因,引入了一个庞大的physx库,这是很不合理的。然后我们还发现这个WASM里面有很多模板展开的代码,就是说拿空间换时间,在某些平台上面可能是比较不错的策略,但对于WebGL这个平台,我们内存特别紧张,所以在这个平台上不是一个好的策略,针对WebGL平台不使用这么多的模板。
所以目前,我们针对这个代码裁剪作为工作,就是把一部分的模板改成了函数参数,不去给它进行展开,然后的话用宏去剔除一些WebGL项目中用不到的模板跟函数,例如像Sqlite,还有physx的一部分功能,以及像computer shader的这种目前不支持的东西也可以把它先踢出去,未来我们还会继续去清理启动流程、主循环里面这些不必要的步骤。还有就是IL2CPP代码的生成占了60%,这一块我们要探索怎么去把这个东西优化掉一些。
现在我们看一下加快启动速度,这里我们主要做了两件事情,一个是说跟平台合作,让平台去提供一个中文字体,这样就避免了每个小游戏里面都自己需要打包一个中文字体,这可以节省5M到10M左右的下载时间。还有,我们可以动态的去裁剪这个unity default resource,因为default resource里面的些资源不见得每个小游戏都得用,目前看来,对于大部分游戏我们可以把default resource从3.5M降到400K左右。我们之前还尝试过通过WASM snapshot的方式进行加载,不过这个方案我们还没跑通。
接下来聊一下未来工作的方向,接下来我们主要的有两块东西,一个是多线程,还有一个是WebGPU,除了这些之外我们还会再探索一些像webAssembly上面的SIMD支持,然后甚至尝试让WebGL平台也去支持burst。
Unity其实现在就已经可以打开WebG的多线程,从浏览器中截下来的profile可以看到,打开的多线程之后,这些job从主线程转移到了Webwork上面。
然后这里还是针对之前那个测试案例尝试使用多线程,在使用多线毫秒降到了每帧消耗大概是在38-40毫秒。
但是目前Unity的多线程实现的还不够稳定、不够完善。例如切换场景的时候可能会Crash,我们目前还不支持Render THread,因为这个Web worker访问不了DOM,然后之前我们也可以讲到,打开了多线程之后,左边这个内存增加了,从1.25G增到了1.69G,内存增加的比较厉害。然后还有一个限制,目前WebGL的多线程只能支持native代码还不支持C#代码,这些都是我们未来努力解决的问题。
然后就是WebGPU,今年四月份的时候,然后Chrom的113就已经支持WebGPU,我们目前正在集成,一边集成一边研究如何去重构我们的GFS device接口层使它更加接近于现代图形API,从更多发挥出WebGPU的性能优势,今天就介绍到这里,谢谢大家。
|