Unity WebGL 开发(三)

这是 WebGL 部分的第三篇,主要包括性能问题、内存、与浏览器的交互三部分内容。

性能问题

WebGL 应用在 GPU 上的性能应该与原生应用接近,因为 WebGL 也会使用 gpu 进行硬件加速,并且从 WebGL API 转换到操作系统的图形 API 性能消耗非常小。

CPU 方面,WebGL 平台将代码转换为 WebAssembly,它的性能取决于浏览器,各个浏览器之间的对比可以参考这里

还有一些其他因素会影响性能,这里主要是 JavaScript 不支持多线程,Unity做的很多多线程优化在 WebGL 平台不起作用。

为了达到最优的性能,建议最终发布时,将 Exception support 设置为 none。

当设置了 runInBackground之后,即使画布或者浏览器失去焦点之后,仍然会在后台运行,然后有些浏览器会对后台运行的标签进行限制,如果 WebGL 内容所在标签不可见之后,将会每秒更新一次,这将会造成 Time.deltaTime 比实际要慢,这是因为 Unity 有个 Time.maximumDeltaTime 设置,这个值默认为 0.1,Time.deltaTime 不会小于此值,就是说实际已经过了 1秒,但是系统只步进了 0.1 秒。

如果想以低帧率运行,可以通过 Application.targetFrameRate 进行设置,如果不需要限制,将该参数设置为 -1,而非一个非常高的值。

内嵌资源

有些 .net 程序集中会内嵌一些资源,默认情况下,WebGL 在打包时是不打包这些资源的,目的是为了减少内容的大小,如果有些 API 确实需要这些资源,可以通过以下代码进行设置:

1
2
3
4
5
6
7
8
9
10
using UnityEditor;

public class WebGLEditorScript
{
[MenuItem("WebGL/Enable Embedded Resources")]
public static void EnableErrorMessageTesting()
{
PlayerSettings.SetPropertyBool("useEmbeddedResources", true, BuildTargetGroup.WebGL);
}
}

当启用设置之后,项目中 .net 程序集的内嵌资源都会打包到 WebGL 发布内容中。

WebGL 内存

内存是制约 WebGL 内容复杂度的一个重要因素,WebGL 内容运行在浏览器中,可用的内存大小取决于浏览器的类型和设备类型,主要由以下几点决定:

  • 浏览器是 32 位还是 64 位;
  • 浏览器是否给每个标签分配单独进程;
  • 浏览器的 JavaScript 引擎所需内存。

下图是 Unity WebGL 在浏览器运行时的内存情况

可以看出,除了 Unity Heap 之外,还需要浏览器分配额外的内存,理解这一点有助于帮助后续的内存优化。

DOM、Unity Heap、Asset Data、Code 这几块内存在加载之后将会常驻内存,其他的像 AssetBundle、WebAudio 等都是由代码进行控制加载和卸载。

Unity Heap

UnityHeap 包括几部分组成:

  • 静态对象内存 : 和 Unity 版本和项目代码有关;
  • 栈内存 : 通常在 5mb 左右;
  • 动态内存 :可以增长的部分,主要用于 GC 缓冲区,运行时生成的对象都在该内存段中;
  • 未分配内存;

UnityHeap 的大小可以通过 PlayerSetting->Memory Size 来设置,默认为 256Mb,在 JavaScript 中,UnityHeap 是通过 TypedArray 来表示的,即一旦分配之后就不能增大或减小,并且这部分内存是不返还给浏览器的。

通常一个空的项目只需要 16Mb 即可运行,堆内存大小可以根据项目复杂度进行调整,但是需要记住,Unity Heap越大,所能使用的用户就越少。因此在实际项目中为了尽可能的减小 Unity Heap 的大小,我们通常需要将我们的项目整个跑一遍,然后通过 Profiler 来查看最终用了多少内存,然后调整一个稍大的值(16整数倍)即可满足需要。

资源数据(Asset Data)

在编译 WebGL 项目时,Unity 会创建一个a.data文件,它包含了项目的所有场景和资源,因为 WebGL 不能访问系统的文件系统,因此只能在开始之前进行下载,然后解压到浏览器分配的一块连续内存,并且会一直常驻在内存中,所以,如果想要提高加载速度,应该尽可能的降低资源的大小。

另一种有推荐的方法是采用 AssetBundle,它具备以下几点优势:

  • 加载时间完全受控,不需要再开始前进行全部加载;
  • 可以进行卸载,不需要常驻内存;
  • 分配在堆内存中,不需要浏览器其他额外分配内存;
  • 可以使用浏览器缓存。

内存相关问题

在调试过程中如果遇到内存相关的问题,首先要区分是浏览器分配失败,还是 Unity 从预分配块中分配失败。如果时浏览器分配失败,这说明 WebGL 内容内存占用过大,应该降低资源大小;如果时 Unity 从块中分配内存失败,这说明在 PlayerSetting 中设置的内存太小,应该适当增加。

那么如何判断这两种情况,不同的浏览器可能显示的结果不同,但是一般的,如果在控制台看到类似 Out of memory的错误,通常是浏览器分配失败;如果在加载内容时发生崩溃,并且没有显示什么错误,造成这种问题的原因很多,但通常都是 Unity 内存分配失败。

Large-Allocation

服务器在相应资源请求是,可以在 Http 头中标记 Large-Allocation,它高速浏览器(目前只有火狐支持)我要使用大量的内存,这可以解决 32 位中堆内存分配问题。

垃圾回收

在堆内存中分配的对象,当没有任何引用指向它们时,将会被当做垃圾进行回收,在 WebGL 平台也是如此。和其他平台不同的是,GC 调用的时机不同,在其他平台,为了执行GC,通常会暂停所有线程,然后检查各个线程栈进行垃圾回收,但是在 WebGL 这是行不通的,因为 WebGL 是单线程,因此只在已知栈为空时WebGL 才进行 GC 调用(目前是每帧结束调用一次)。

这通常不会有什么问题,因为每帧的产生的垃圾内存很少,但是下面一段代码时不可行的,因为 WebGL 是单线程,在循环过程中,根本没有机会执行 GC 操作,所以造成的结果就是垃圾越来越多,最后内存分配失败。

1
2
3
4
5
6
string hugeString = "";

for(int i=0;i<10000;i++)
{
hugeString += "foo";
}

关于内存的更多信息,可以参考Understanding Memory in Unity WebGL,和Unity WebGL Memory: The Unity Heap

与浏览器交互

在构建 WebGL 项目中,可能会需要与 Html 中的元素进行交互,或者通过 WebAPI 实现某些功能,这些都需要和浏览器的 JavaScript 进行交互,下面介绍一下应该怎么做。

从 C# 调用 JavaScript 代码

为了调用 JavaScript 代码,首先需要将 JavaScript 文件后缀名修改为 .jslib,并且放到 Assets/Plugins/WebGL 目录,JavaScript 文件需要按照下面的格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
mergeInto(LibraryManager.library, {

Hello: function () {
window.alert("Hello, world!");
},

HelloString: function (str) {
window.alert(Pointer_stringify(str));
},

PrintFloatArray: function (array, size) {
for(var i = 0; i < size; i++)
console.log(HEAPF32[(array >> 2) + i]);
},

AddNumbers: function (x, y) {
return x + y;
},

StringReturnValueFunction: function () {
var returnStr = "bla";
var bufferSize = lengthBytesUTF8(returnStr) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(returnStr, buffer, bufferSize);
return buffer;
},

BindWebGLTexture: function (texture) {
GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[texture]);
},

});

在 C# 端,按照下面格式进行导入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using UnityEngine;
using System.Runtime.InteropServices;

public class NewBehaviourScript : MonoBehaviour {

[DllImport("__Internal")]
private static extern void Hello();

[DllImport("__Internal")]
private static extern void HelloString(string str);

[DllImport("__Internal")]
private static extern void PrintFloatArray(float[] array, int size);

[DllImport("__Internal")]
private static extern int AddNumbers(int x, int y);

[DllImport("__Internal")]
private static extern string StringReturnValueFunction();

[DllImport("__Internal")]
private static extern void BindWebGLTexture(int texture);

void Start() {
Hello();

HelloString("This is a string.");

float[] myArray = new float[10];
PrintFloatArray(myArray, myArray.Length);

int result = AddNumbers(5, 7);
Debug.Log(result);

Debug.Log(StringReturnValueFunction());

var texture = new Texture2D(0, 0, TextureFormat.ARGB32, false);
BindWebGLTexture(texture.GetNativeTextureID());
}
}

在传递参数和返回值时,需要注意以下几点:

  • 数值类型不需要进行转换即可传输,其他类型需要转换为指针进行传输;
  • 向 JavaScript 传 string 类型,需要调用 Pointer_strigify(str) 转化为 JavaScript 字符串;
  • 从 JavaScript 返回 string 类型,需要使用 _mallocstringToUTF8,具体参考上面StringReturnValueFunction方法;
  • 数组类型可以通过 HEAP8等类型进行访问,具体参考上面PrintFloatArray方法;
  • 要从 JavaScript 访问贴图,可以通过 GL.textures 字典,键值为 texture 的指针值。
JavaScript C#
HEAP8 sbyte
HEAPU8 byte
HEAP16 short
HEAPU16 ushort
HEAP32 int
HEAPU32 uint
HEAPF32 float
HEAPF64 double

JavaScript 调用 C# 代码

从 JavaScript 调用 C#方法,需要获取 unityInstance 对象的 SendMessage 函数,具体格式如下:

1
unityInstance.SendMessage(objectName,methodName,value);
  • objectName : 场景中物体的名称;
  • methodName : 要调用的方法的名称;
  • value :传递的参数,只能值数值类型、字符串、或为空。

调用 C 方法

在其他平台如果想要调用 C 函数,需要将 C 代码编译为动态链接库,而在 WebGL 平台则是通过emscripten 工具将 C 的源码转换为 JavaScript 代码,所以需要将 C 源码文件,放到 Assets/Plugins/WebGL 目录中。C 代码的编写和其他平台类似,如果时 C++ 代码,需要导出 C 声明接口。

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

extern "C" void Hello ()
{
printf("Hello, world!\n");
}

extern "C" int AddNumbers (int x, int y)
{
return x + y;
}

WebGL 模板

unity 在打包 WebGL时,会将 player 嵌套进一个 Html 文件中,Unity 有两个内置的 hmtl 模板可以选择,但是在正式生产中,我们可能需要根据自己的需要,进行定制,可以按照下面的方法进行模板定制。

首先必须将自定义模板放到 Assets/WebGLTemplates/模板名称 文件夹内,每个模板必须包含一个 index.html文件,依赖的资源也需要放置到该文件夹内,当模板创建完成之后,可以在PlayerSetting中选择创建的模板,可以通过 thumbnail.png(128*128 像素)文件给刚创建的模板,添加一个预览图标,方便进行选择。

index.html 文件至少需要包含三个元素:

  • WebGL Loader 的脚本标签:<script src="%UNITY_WEBGL_LOADER_URL%"></script>;
  • 实例化脚本标签:<script> var unityInstance = UnityLoader.instantiate("unityContainer", "%UNITY_WEBGL_BUILD_URL%");</script>;
  • 一个 div 标签,包含一个 id 属性,其值为实例化中的第一个参数:’\
    \
    ‘。

UnityLoader.instantiate(container, url, override)

container,用于展示内容的容器,通常传入 div 的 id;
url,打包时 json 文件地址,通常指定一个宏:%UNITY_WEBGL_BUILD_URL%
override,可选项,用于重写某些预制行为。

模板中可以使用的宏

可以通过宏来获取打包内容的信息,宏的格式为 %宏名称%,在打包时会将这些宏进行展开,可用的宏列表如下:

  • UNITY_WEBGL_BUILD_URL : PlayerSetting 中的 ProductName;
  • UNITY_WEBGL_LOADER_URL : UnityLoader.js 的 url;
  • UNITY_WEBGL_BUILD_URL : 生成的 json 文件的 url;
  • UNITY_WIDTH,UNITY_WIDTH : Player 的宽度和高度(像素值);
  • UNITY_CUSTOM_SOME_TAG : 如果使用了 UNITY_CUSTOM_XXX这种格式的宏,当选中这个模板时,Unity 将会在属性面板中显示一个输入框,可以设置标签的值。

添加进度条

Unity WebGL 会提供一个默认的加载进度条,当然可以进行自定义。通过重写 UnityLoader.instantiate 中的 override 的 onProgress 行为。

1
var unityInstance = UnityLoader.instantiate("unityContainer", "%UNITY_WEBGL_BUILD_URL%", {onProgress: UnityProgress});

UnityProgress 方法包含两个参数,第一个参数表示 unityInstance 对象,第二个参数表示加载进度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function UnityProgress(unityInstance, progress) {
if (!unityInstance.Module)
return;
if (!unityInstance.logo) {
unityInstance.logo = document.createElement("div");
unityInstance.logo.className = "logo " + unityInstance.Module.splashScreenStyle;
unityInstance.container.appendChild(unityInstance.logo);
}
if (!unityInstance.progress) {
unityInstance.progress = document.createElement("div");
unityInstance.progress.className = "progress " + unityInstance.Module.splashScreenStyle;
unityInstance.progress.empty = document.createElement("div");
unityInstance.progress.empty.className = "empty";
unityInstance.progress.appendChild(unityInstance.progress.empty);
unityInstance.progress.full = document.createElement("div");
unityInstance.progress.full.className = "full";
unityInstance.progress.appendChild(unityInstance.progress.full);
unityInstance.container.appendChild(unityInstance.progress);
}
unityInstance.progress.full.style.width = (100 * progress) + "%";
unityInstance.progress.empty.style.width = (100 * (1 - progress)) + "%";
if (progress == 1)
unityInstance.logo.style.display = unityInstance.progress.style.display = "none";
}