理解Unity WebGL中的内存模型
Posted 说给开发游戏的你
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了理解Unity WebGL中的内存模型相关的知识,希望对你有一定的参考价值。
今天偷个懒,翻译一篇Unity官博的文章,话题是WebGL平台App的内存管理。
Unity WebGL与其他平台有何不同?
最关键的一点就是内存严重受限。
一部分Unity WebGL的开发者早已熟悉类似的内存受限的平台了,但是大多数开发者是从Desktop或者WebPlayer转来的,之前并没有太关注过内存受限的问题。
主机平台的开发者处理这类内存问题比较容易,毕竟可用内存量是已知的。
而对于移动平台的开发者,因为面对的设备各种各样,所以处理起来稍微复杂一点。但是至少能根据设备保有率情况确定一个支持下限,把不符合配置的设备列入黑名单。
但是Web平台的开发者处理这类问题就比较棘手。理想情况下,所有的客户端都用64位浏览器,都有用不完的内存。但是现实很残酷,开发者只能拿到OS、浏览器信息,没办法获取客户端的硬件配置。更何况,客户端有可能运行你的Unity WebGL App(译注,下文都简称WebGL App)的同时还在开着多个其他网页。
综上这些,Unity WebGL的内存管理成了一个大问题。
综述
下面是浏览器运行WebGL App时的内存结构图:
从图中可以看出,WebGL App运行时,除了Unity Heap外,浏览器还会额外申请其他的内存。理解了这部分,你才能更有效地优化自己的项目,从而降低玩家的流失率。
图中列了各种内存分配:DOM、Unity Heap、AssetData、Code这几部分会在网页加载时常驻内存。而其他的比如Asset Bundles、WebAudio、MemoryFS部分的内存占用会根据App行为动态变化(比如asset bundle下载、audio播放等等)。
浏览器在加载过程中会解析、编译asm.js,也会产生数次临时内存分配,有时还会导致部分32位浏览器内存耗尽。
Unity Heap
Unity Heap中通常包括逻辑中的各种game objects、compnents、textures、shaders,等等。
在WebGL中,浏览器为Unity Heap分配内存之前需要提前知道大小,并且之后这块buffer不会再收缩(shrink)或增长。
分配UnityHeap的代码就是这样一行:
buffer = new ArrayBuffer(TOTAL_MEMORY);
这行代码在build.js中,浏览器的JS VM会执行这行代码。
我们可以在Unity的PlayerSettings中定义TOTAL_MEMORY(WebGL Memory Size参数),默认值是256mb(只是Unity官方随便定的一个值)。空项目的话只需要16mb。
实际项目往往需要更多内存,大部分情况需要256mb、386mb或更多。不过要注意,你的WebGL App需要的内存越多,能运行你的App的客户端就越少。
Source/Compiled Code的内存消耗
浏览器VM执行代码前,还有如下几个步骤:
下载代码。
拷贝到text blob中。
编译。
我们看一下各步骤的内存消耗情况:
下载需要一块临时buffer,但是Source和Compiled Code需要在内存中持久化。
下载buffer和Source buffer的大小跟Unity生成的未压缩js大小差不多。可以用如下方法估计一下这块的大小:
1.build一个release版本。
2.把jsgz和datagz重命名为*.gz,用压缩工具解压。
3.解压后的大小就是浏览器需要的内存大小。
Compiled Code的大小取决于浏览器。
打开Strip Engine Code选项是最简单的优化方式,这样build出的版本就不会包括项目不需要的引擎native code(比如你不需要2D物理模块的话,这部分就会被strip掉)。不过,Unity总是会对托管部分代码做strip。
注意,Exception的支持和一些第三方插件也会导致代码体积增长。不过,我们也遇到有开发者发布版本只需要对象null check和数组越界检查,而不需要完整exception支持(这会导致内存和性能的overhead)的情况。只需要给il2cpp传"–emit-null-checks"和"–enable-array-bounds-check"参数即可:
PlayerSettings.SetPropertyString("additionalIl2CppArgs", "--emit-null-checks --enable-array-bounds-check");
最后,毫无疑问,勾上Development build选项,Unity就不会做Minification ,会导致代码体积增大。
Asset Data
其他平台的应用访问持久存储媒介(比如硬盘、闪存等)很容易。但是由于Web平台并没有真的文件系统,无法办到这点。因此,只要WebGL App的数据(.data 文件)下载下来,就会存在内存中。这样的缺点就是相比其他平台需要更多的内存(在5.3版本中。.data文件经过lz4压缩存在内存中)。下图是profiler的例子,.data文件大约40mb(以及256mb Unity Heap):
.data文件中有什么?简单来说就是unity生成的一组文件:包括data.unity3d(包括所有scene,scene依赖的assets,Resources目录中的所有),unity_default_resources,以及少量引擎需要的小文件。
我们build完一个WebGL项目,就可以到Temp\StagingArea\Data(注意,Unity Editor关闭的时候会删掉Temp目录)查看data.unity3d的具体大小。或者,你也可以直接在UnityLoader.js中查看,构造DataRequest的offset参数就表示data.unity3d的大小。
new DataRequest(0, 39065934, 0, 0).open('GET', '/data.unity3d');
Memory File System
尽管WebGL中并没有真的文件系统存在,但是App仍然可以read/write文件。跟其他平台的区别就是所有的文件I/O操作都只会read/write内存。需要注意的是,这个内存文件系统并不在Unity Heap中,因此它需要额外的内存。举个例子,我们把一块buffer写到「文件」中:
var buffer = new byte [10*1014*1024];File.WriteAllBytes(Application.temporaryCachePath + "/buffer.bytes", buffer);
可以在浏览器的profiler中看到,这个「文件」会写到内存中:
ps,上图app的Unity Heap是256mb。
类似地,由于Unity的缓存系统基于文件系统构建,因此所有缓存都存在内存中。这意味着,像PlayerPrefs以及cached Asset Bundles都会在内存中(Unity Heap之外)常驻。
Asset Bundles
在Unity WebGL中降低内存消耗的最佳实践自然是用Asset Bundle。然而,不同的用法会对App的内存占用(包括对Unity Heap的占用和其他部分的内存占用)产生不同的影响————如果用法不当,可能会导致你的App没办法在32位浏览器上运行。
既然已经必须用asset bundle了,具体应该怎么做呢?难道要把所有的assets导出到同一个asset bundle中?
显然不是。虽然这样做确实可以降低网页加载时间,但是这样需要下载一个(有可能非常大的)asset bundle,导致一次memory spike。我们看下下载asset bundle前的App内存情况:
如图所示,Unity Heap占用256mb。
下图是未缓存情况下,下载完了一个asset bundle:
可以看到XHR(译注,XMLHttpRequest)分配了一块额外的buffer,大小近似于bundle在磁盘中的大小(65mb左右)。虽然这只是个临时buffer,但是会在GC之前导致数帧的memory spike。
那怎样做才能减少memory spike?为每个asset创建一个asset bundle吗?这个想法很有趣,但是一点也不实际。
结论是并没有通用准则,你需要根据项目需求定制打包方案。
最后,切记在不需要资源时,调用AssetBundle.Unload。
Asset Bundle缓存
WebGL平台的Asset Bundle缓存机制跟其他平台类似,用户只要用WWW.LoadFromCacheOrDownload就可以了。不过有一点区别相当重要,仍然跟内存消耗有关。在Unity WebGL中,Asset Bundle的缓存底层基于IndexedDB(目前的emscripten版本中,基于内存文件系统实现)。
我们来看一下用LoadFromCacheOrDownload下载一个asset bundle之前的内存占用情况:
如图所示,UnityHeap用了512mb,其他分配用了4mb左右。加载bundle之后:
其他分配飙升到了167mb左右。这就是上面所说的asset bundle(压缩后64mb左右)所需要的内存。js vm GC后:
稍微好了点,但是还是需要85mb左右:其中大部分都用来在内存文件系统中缓存该asset bundle。这部分内存即使是unload bundle了也拿不回来。还有,当用户用浏览器第二次打开app,这部分内存会早于load bundle,立刻分配。
举个参考例子,下图是Chrome的内存快照:
同样可以在Unity Heap外看到一块临时内存分配,也是asset bundle系统的缓存机制需要用的。有一个坏消息是我们最近发现这部分会分配比所需更多的空间。但是好消息是这个bug已经在Unity 5.5 Beta 4、5.3.6 Patch 6、5.4.1 Patch 2中修复。
对于老版本Unity,如果你的WebGL项目已经或者接近上线,并且不想升级Unity版本,也有workaround,可以在editor script设置如下属性:
PlayerSettings.SetPropertyString("emscriptenArgs", " -s MEMFS_APPEND_TO_TYPED_ARRAYS=1", BuildTargetGroup.WebGL);
减少asset bundle缓存系统内存占用的长效解决方案是用WWW Constructor代替LoadFromCacheOrDownload(),或者如果你在用新UnityWebRequest API的话,可以用不带version参数或hash参数的UnityWebRequest.GetAssetBundle()版本。
或者还可以在XMLHttpRequest层采用替代的缓存机制,这样下载的文件就可以绕过内存文件系统,直接存到indexedDB。这也是我们近期的工作成果之一,已经在asset store发布(译注,https://www.assetstore.unity3d.com/en/#!/content/71538)。你可以在自己的项目中按需定制使用。
Asset Bundle压缩
5.3和5.4版本同时支持LZMA和LZ4压缩。虽然LZMA(默认选项)相比于LZ4/Uncompressed会有略优的压缩比表现,但是LZMA会在WebGL平台上存在一些问题:LZMA算法会导致明显的执行卡顿,而且需要更多的内存。因此,我们强烈建议不压缩或用LZ4算法(事实上,Unity5.5的WebGL中将不再提供LZMA asset bundle压缩选项),这样一来,为了抵消下载尺寸增大带来的影响,你可以用gzip/brotli对asset bundle做压缩,并对应修改服务器配置。
WebAudio
Unity WebGL的音频实现跟其他平台不一样,那么这对于内存占用意味着什么?
Unity会在javascript侧创建特定的AudioBuffer对象,这样就可以用WebAudio播放。
由于WebAudio缓存位于Unity Heap之外,不能被Unity profiler追踪,所以你要用浏览器特定的工具去查看音频相关的内存占用。如图所示(用Firefox的about:memory页):
考虑到这些Audio Buffers会hold住未压缩数据,那如果是体积较大的audio clip assets就会出现问题(例如背景音乐)。对于这个问题,你可能需要定制js插件,这样就能自己直接操作<audio> tag。这样的话既能压缩音频文件,又能减少内存占用。
FAQ
降低内存占用的最佳实践?
总结一下:
减小Unity Heap的体积。
「WebGL Memory size」配的越小越好。
减小代码体积。
打开 Strip Engine Code选项。
关闭 Exceptions。
尽量少用3rd party plugins。
减小数据体积。
用 Asset Bundles。
用 Crunch texture compression。
有没有一个策略可以确定WebGL Memory Size的最小值?
当然,最佳策略是用memory profiler分析你的app实际上需要多少内存,然后对应修改WebGL Memory Size。
以一个空工程为例。在Memory Profiler中可以看到Total Used列指示的数量,刚好比16MB多点(不同版本的unity具体值不同):也就是说我要把WebGL Memory Size设置的比这个值稍大。而Total Used的值取决于你的app。
然而,如果由于一些原因没法用Profiler,你只需要不断调低WebGL Memory Size,直到找到合适的、可以运行你的app的值即可。
注意,由于Emscripten的存在,如果配置值不是16的倍数,会自动在运行时round。
WebGL Memory Size (mb) 配置项会决定生成的html里面的TOTAL_MEMORY (bytes)值:
所以,如果想不停调这个值,又不想rebuild工程,最好直接改html。这样,等找到了合适的值,再在Unity工程中修改WebGL Memory Size配置就行了。
最后需要提醒一下,Unity profiler还会额外占用一部分内存,所以profiling的时候需要稍微调高些WebGL Memory Size。
我构建的版本内存耗尽了,如何修复?
这需要具体看是Unity内存耗尽了还是浏览器内存耗尽了。错误信息可以指示具体问题以及解决方案:“If you are the developer of this content, try allocating more/less memory to your WebGL build in the WebGL player settings.”然后你就可以对应调整WebGL Memory Size配置项。不过,要解决内存耗尽问题还有一些其他工作。如果你看到这条错误消息:
除了消息本身的建议外,你还可以试着减小代码体积或数据体积。因为浏览器加载页面的时候会做内存分配,比较关键的几部分是:代码、数据、unity heap、编译后的asm.js。这部分的内存占用相当大,尤其是数据和Unity heap,可能会在32位浏览器上出问题。
有些情况下,即使内存足够,但是由于碎片存在,浏览器仍然会报错。这也是有时重启浏览器又能正常加载app的原因。
还有一种情况是Unity耗尽内存,会弹出如下消息:
这种情况的话,你需要优化你的Unity项目。
我如何估算内存消耗值?
要分析浏览器运行app需要消耗的内存,可以用Firefox Memory Tool或Chrome Heap查看内存快照。但是,这两个工具都没法看WebAudio的内存占用情况,但是可以用about:memory(Firefox):快照,搜索「webaudio」。如果想用JS profile内存,可以试下window.performance.memory(仅支持Chrome)。
要估算Unity Heap内部的内存使用情况,用Unity Profiler即可。之前提到过,想用profiler需要稍微调高WebGL Memory Size。
另外,我们还做了个新工具来分析build内容:使用方法是先build一个WebGL版本,然后访问http://files.unity3d.com/build-report/。虽然现在Unity5.4可以用这个工具,但是由于只是个半成品,有可能随时变动或者移除。不过我们目前会拿出来做测试用。
WebGL Memory Size的最大值最小值是?
最小值是16,最大值是2032,我们通常建议设置在512以下。
开发环境下可以分配超过2032MB内存吗?
这是一个技术限制:JS中用TypeArray实现Unity heap,TypeArray的长度用32位signed integer表示,2048MB会导致溢出。
为什么不把Unity Heap做成可调整大小的?
我们之前考虑过加一个ALLOW_MEMORY_GROWTH emscripten flag,用来标记Heap的调整大小开关。但是至今仍没这样做,因为这样会导致Chrome的一些优化不可用。
我们也做了一些benchmark,认为这样会导致更严重的内存问题。假设app运行着发现Unity Heap不够用需要增长,然后浏览器就要重新分配一块更大的heap,拷贝,再回收老的heap。这样一来,就需要更多的内存(因为老heap和新heap会同时存在)。因此支持动态调整大小的话内存占用会更高。
为什么32位浏览器会在64位系统上耗尽内存?
32位浏览器的内存限制无视操作系统是32位还是64位。
总结
最后的建议是除了Unity profiler之外,还要用浏览器特定的工具profile你的Unity WebGL app,因为前面有说,Unity profiler无法记录Unity Heap之外的内存分配。
个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。
以上是关于理解Unity WebGL中的内存模型的主要内容,如果未能解决你的问题,请参考以下文章