Unity Web 中的内存限制可能会限制您运行的内容的复杂性。
Web 内容在浏览器中运行。浏览器在其内存空间中分配应用程序运行内容所需的内存。可用内存量因以下因素而异:
注意:有关 Web 内存的安全风险的信息,请参阅安全和内存资源。
Unity Web 内容在几个方面需要浏览器分配大量内存。
Unity 使用内存堆存储所有 Unity 引擎运行时对象。这些包括托管和原生对象、加载的资产、场景和着色器。这类似于 Unity 播放器在其他平台上使用的内存。
Unity 堆是分配的连续内存块。Unity 支持自动调整堆大小以满足应用程序的需要。堆大小可随着应用程序运行而扩展,最多可以扩展到 2GB。Unity 将此内存堆创建为内存对象。内存对象的缓冲区属性是可调整大小的 ArrayBuffer,用于保存 WebAssembly 代码访问的内存的原始字节。
如果浏览器无法在地址空间中分配连续的内存块,则自动调整堆大小可能会导致应用程序崩溃。出于此原因,应使 Unity 堆大小尽可能小,这十分重要。因此,在规划应用程序的内存使用量时要谨慎。如果要测试 Unity 堆的大小,可以使用性能分析器对内存块的内容进行性能分析。
您可以使用 Web 播放器设置中的内存增长模式 (Memory Growth Mode) 选项来控制堆的初始大小和增长。默认选项配置为适用于所有桌面端用例。但是,对于移动端浏览器,需要使用高级调整选项。对于移动端浏览器,建议将初始内存大小 (Initial Memory Size) 配置为应用程序的典型堆使用量。
创建 Unity Web 构建时,Unity 会生成一个 .data 文件。这包含应用程序需要启动的所有场景和资产。因为 Unity Web 无法访问实际文件系统,所以会创建虚拟内存文件系统,浏览器会在此处解压缩 .data 文件。Emscipten 框架 (JavaScript) 在浏览器内存空间中分配此内存文件系统。内容运行时,浏览器内存会保留未压缩的数据。为了缩短下载时间和降低内存使用量,应尽量保持未压缩数据尽可能小。
为了减少内存使用量,可以将资产数据打包到 AssetBundle 中。通过 AssetBundle 可以完全控制资产下载。可以控制应用程序何时下载资产,以及运行时何时卸载它。您可以卸载未使用的资产以释放内存。
AssetBundles 直接下载到 Unity 堆中,因此这些内容不会导致浏览器进行额外分配。
启用数据缓存可将内容中的资产数据自动缓存在用户计算机上。这意味着无需在以后的运行过程中重新下载该数据。Unity Web 加载程序使用 IndexedDB API 实现数据缓存。此选项让您可以缓存对于浏览器而言太大而无法在本机缓存的文件。
数据缓存使浏览器能够将应用程序数据存储在用户的计算机上。浏览器通常会限制可以在其缓存中存储的量以及可以缓存的最大文件大小。这通常不足以使应用程序顺畅运行。Unity Web 加载器缓存,带有 IndexedDB API,允许 Unity 将数据存储在 IndexedDB 中而不是浏览器缓存中。
要启用数据缓存选项,请转到文件 (File) > 构建设置 (Build Settings) > 播放器设置 (Player Settings) > 发布设置 (Publishing Settings)。
垃圾收集是查找和释放未使用内存的过程。有关 Unity 垃圾收集工作原理的概述,请参阅自动内存管理。请使用 Unity 性能分析器调试垃圾收集过程。
由于 WebAssembly 的安全限制,不允许用户程序检查本机执行栈以防止可能的漏洞。
这意味着,在 Web 平台上,GC 只能在没有托管代码执行时运行(这可能引用实时 C# 对象)。这发生在每个渲染的游戏帧的末尾。
换句话说,在 Web 平台上,垃圾回收器不能在执行 C# 代码的中间运行,只能在每个程序帧的末尾运行。与其他平台相比,此差异会导致 Web 上的垃圾收集行为出现一些差异。
由于这些差异,请密切关注每帧执行大量临时分配的代码,尤其是在这些分配可能呈现一系列线性大小增长的情况下。此类分配可能会导致垃圾回收器出现暂时的二次内存增长压力。
例如,如果您有一个长时间运行的循环,则在 Web 上运行以下代码可能会失败,因为垃圾回收器不会在 for 循环的迭代之间运行。这意味着垃圾回收器无法释放中间字符串对象使用的内存,可能会耗尽 Unity 堆中的内存。
string hugeString = "";
for (int i = 0; i < 100000; i++)
{
hugeString += "foo";
}
在上面的示例中,循环末尾的 hugeString 长度为 3 * 100000 = 300000 个字符。但是,该代码会在生成最终字符串之前生成十万个临时字符串。整个循环分配的总内存为 3 * (1 + 2 + 3 + … + 100000) = 3 * (100000 * 100001/2) = 15 GB。
在原生平台上,循环执行时,垃圾回收器会持续清理字符串的先前临时副本。因此,上述代码总共不需要 15 GB RAM 即可运行。
在 Web 平台上,垃圾回收器在帧结束时才会回收临时字符串副本。因此,上述代码在尝试分配 15 GB RAM 时内存不足。
以下代码显示了第二个可能发生此类临时二次内存压力的示例:
byte[] data;
for (int i = 0; i < 100000; i++)
{
data = new byte[i];
// do something temporary with data[]
}
此处的代码会临时分配 1 + 2 + 3 + … + 100000 字节 = 5 GB 字节增长,即使仅保留最后的 100 KB 数组也是如此。这会导致程序在 Web 平台上看起来内存不足,即使最终输出中只需要 100 KB。
要限制这些类型的问题,要避免使用那些会使临时内存分配量呈二次方增长的代码结构。相反,应预先分配最终所需的数据大小,或使用 List<T> 或类似的数据结构来执行呈几何级数增长的容量保留,以减轻临时内存压力。
例如,对于 List<T> 容器,如果知道数据结构的最终大小,请考虑使用 List<T>.ReserveCapacity() 函数来预分配所需的容量。同样,在缩小以前容纳了几兆字节内存的容器大小时,请考虑使用 List<T>.TrimExcess() 函数。
注意:使用 C# 委托或事件(例如 Delegate、Action、Func)时,这些类在内部使用类似的线性增长分配。避免使用这些类进行过多的每帧委托注册和取消注册,以最大限度减少 Web 平台上垃圾回收器的临时内存压力。