WebAssembly 在白鹭引擎5.0中的实践

作为一种可移植、体积小、加载快且兼容web的全新格式,WebAssembly受到诸多关注,并迎来企业的探索实践。白鹭引擎利用WebAssembly重新实现了一个新的渲染内核并作为一个可选项提供给开发者,使得白鹭引擎5.0成为业内首个双核驱动的引擎。在此过程中积累了一些经验,白鹭引擎首席架构师王泽今天和大家一起分享背后的故事。


WebAssembly 是 Google Chrome、Mozilla FireFox、Microsoft Edge共同宣布支持、并在 2017年3月份在各自浏览器中提供了实现的一种新技术。他被设计为一种可移植的、安全的、低尺寸的、高效的二进制格式。浏览器可以解析并运行这种格式,并拥有比 JavaScript 更高的性能和解析速度。WebAssembly 可以通过编写 C / C++ 代码,通过专门的编译器生成 .wasm 格式的文件,直接运行在最新的浏览器中。


白鹭引擎是一款 HTML5 游戏引擎,他提供了游戏开发所需要的诸多功能,并允许开发者编写的游戏运行在 Web 浏览器或移动应用的 WebView 容器中。


在白鹭引擎 5.0 中,我们使用 WebAssembly 重新编写了白鹭引擎的渲染核心,以便进一步提升渲染效率。在这个过程中,白鹭引擎遇到了WebAssembly的各种问题,在此与读者进行一些 WebAssembly 在实践中遇到的问题及解决方案,希望对计划或者正在使用WebAssembly 的开发者有所帮助。


WebAssembly 的生成原理

上图展示了如何通过编写 C / C++代码生成 WebAssembly 内容。


首先通过 LLVM ,将 C/C++ 源代码编译为 LLVM bytecode。这 是一种跨语言的底层虚拟机 字节码,理论上所有强类型编程语言均可以生成这种字节码。通过这一点可以得知,在未来理论上所有强类型编程语言(诸如 Java / C# 等)均可以开发 WebAssembly 程序。


其次,通过 Emscripten 中的后端编译器,将这种抽象字节码生成 asm.js 格式的文件。这是一种特殊的 JavaScript 代码,一些 JavaScript 引擎会将这种格式以比通常的 JavaScript 代码更快的速度运行,并且由于 asm.js 仍然是 JavaScript,所以哪怕 JavaScript 引擎不支持该特 性,也会以通常的方式运行这段逻辑。这意味着使用 C/C++编写的源代码,哪怕用户设备不支持 WebAssembly,也可以回退到 JavaScript 运行并得到一致的结果。


接下来,asm.js 会通过另一个编译器生成为 WebAssembly 的 .wasm 文件,由于 WebAssembly 是二进制格式,相比 JavaScript 而言,其代码体积同比小很多,并且由于已 经是面向机器码的格式,也无需在运行前对源代码耗费时间进行 JIT 编译操作。


通过上述内容可以看出,WebAssembly 理论上可以通过任何强类型语言生成,不强制依赖用 户的本地运行环境,代码体积小、解析速度快,几乎是彻底解决了 JavaScript 的各种顽疾。


WebAssmely 项目入门

开发环境配置

介绍完 WebAssembly 的机制原理,接下来笔者介绍一下如何使用 WebAssembly 开发第一个 HelloWorld 程序。


如果您想开发 WebAssembly,强烈建议您收藏一下三个站点:


WebAssembly 官网:webassembly.org/

WebAssembly MDN:WebAssembly

Emscripten 官网:Main - Emscripten 1.37.22 documentation


在具体的开发中遇到的问题,大部分在这三个网站中可以找到答案。


首先,进行项目开发前需要配置 WebAssembly 开发环境,笔者以 Windows 为例,MacOS 与 Linux 开发者可以阅读 Emscripten 官网文档。


在 Windows 中,可以直接从 Emscripten 官网下载 EmscriptenSDK,安装后,在命令行输入 emcc -v,可以看到显示当前版本号为 1.35.0。为了保证最佳的开发体验,我们需要手动升级 EmscriptenSDK 到最新版本,执行以下命令:

# 获取当前版本信息

emsdk update

# 安装最新版本,笔者目前为 1.37.14

emsdk install latest

# 使用最新版本

emsdk activate latest

在安装过程中,由于需要下载文件,考虑到国内的特殊网络环境,有时下载会失败,读者可以根据下载时候的日志输出,提前将要下载的文件放置于正确路径,然后再执行安装命令。


编写 HelloWorld 应用


在保证 Emscripten 处于最新版本后,就可以开始编写 HelloWorld 应用了。


创建一个新的 C 文件,名为 main.c,编写以下内容

#include <stdio.h>

int main() {

printf("hello, world!\n");

return 0;

}


然后在终端中执行以下命令emcc main.c -o out/index.html最终会生成以下项目结构

project-root

|-- main.c

|-- out/index.html

|-- out/index.js


读者应该已经发现,生成的代码并不包含 WebAssembly 的 wasm 格式文件,而是一个名为 index.js 的 asm.js 五年。这是因为 Emscripten 最初是为了生成 asm.js 格式而设计的。为了生成 wasm,需要额外添加一个参数 emcc main.c -o out/index.html -s WASM=1,当添加这个参数后,Emscripten 会再通过一个名为 Binaryen 的编译器将 asm.js 格式转换为 wasm 格式。


细心的读者可能会发现,理论上 Binaryen 无需 asm.js 这个中间格式,而应该是直接从 C++ 生成的 LLVM 去直接输出 wasm 格式,目前 Binaryen 已经支持了这种方式,但是目前还在测试阶段,所以默认行为仍然是通过 asm.js 作为中间层。

添加完上述参数后重新执行,就会发现项目中生成了名为 index.wasm 的文件,运行 index.html,可以看到屏幕上输出了 Hello,World。


与 JavaScript 进行交互


除了标准C之外,Emscripten 提供了大量函数,用于 JavaScript 、HTML 与 WebAssembly 进行通讯,其最简单的代码如下所示:

#include <emscripten.h>

int main() {

EM_ASM( alert("hai"));

return 0;

}


通过引入 emscripten.h 头文件,就可以调用这些函数,上述代码中展示了如何在 WebAssembly 中直接调用 JavaScript 内容。


为了简化调用,Emscripten 提供了 EMSCRIPTEN_BINDING 等API,可以将一个 C++ 类和函数与 JavaScript 进行直接绑定。


由于 WebAssembly 与 JavaScript 的调用存在着一定的性能问题,所以更推荐开发者使用 typed_memory_view 的方式,将 WebAssembly 中的一段内存与 JavaScript 的一段 TypedArray进行绑定,通过这种方式,WebAssembly 与 JavaScript 的调用不是通过拷贝数据、而是直接对内存进行共享的方式进行交互。通过灵活运用这种方式,可以大幅提升性能,具体一些实际案例可以参见下文的“白鹭引擎的 WebAssembly 实践”了解更多信息。


白鹭引擎的 WebAssembly 实践


在网页端运行一款游戏的几种方式


通过浏览器插件机制,在网页插件中运行游戏,如 Flash Player、Unity Web Player 等。这种机制的优势是由于插件本身使用 NativeCode 对游戏组件进行了许多封装,所以运行效率很高,缺点则是需要浏览器支持,而现在浏览器更加倾向于无插件化。


其次是游戏逻辑和游戏引擎均交由 JavaScript 进行处理,最终渲染则通过控制 DOM 节点或者 操作 DOM-Canvas 相关 API去实现。这种方式实现了无插件化,但是由于 JavaScript 自身性能存在瓶颈,性能也有一定的局限性。目前市面上绝大多数 HTML5 游戏引擎(包括白鹭引擎)均是如此实现,扩展到 WebApp 开发行业,无论是 Angular、React还是其他诸多框架的核心架构也是如此。


由于 WebAssembly 的引入,一些大型游戏引擎厂商,比如 Unity3D,开始尝试将其游戏源代码编译为 WebAssembly,运行浏览器中,这种做法理论上可以把大量基于C/C++编写的游戏发布为 HTML5 版本,但由于 HTML5 游戏本身的资源加载机制与客户端游戏完全不同,直接转换的游戏仍然需要改造很多逻辑去适应网页端“边加载边进行游戏”的需求,否则当用户进入游戏时,需要加载上百兆的游戏资源才能进入游戏,这带来了极其糟糕的体验,并且很占用内存。


由于将整个客户端游戏直接发布为 WebAssembly 格式目前并不成熟,所以我们认为把游戏中性能消耗较大的部分转为 WebAssembly,而将需要强调开发效率的部分继续使用 JavaScript 是一种灵活的方式。


在上述四种方案中,主要是后两种采用到了 WebAssembly 技术,在目前来看,由于第四种方案较为稳妥,所以白鹭引擎采用了这种方案,在最新版本5.0中提供了基于 WebAssembly 的渲染内核,而游戏逻辑本身仍然运行在 JavaScript 环境中。


JavaScript 与 WebAssembly 互操作性能很差


以白鹭引擎5.0的渲染库为例,白鹭引擎对外提供 JavaScript API,开发者编写的 JavaScript 逻辑代码会汇总为一组命令队列发送给 WebAssembly 层,然后 WebAssembly 建立对渲染节点的抽象封装,并在每一帧对这些渲染节点进行矩阵计算、渲染命令生成等逻辑,最终生成一组 ArrayBuffer 数据流,最后 JavaScript 对这组数据流进行简单的解析并直接调用 DOM 的WebGL 接口,把数据流传递给浏览器层。


这个过程中存在着几个性能瓶颈:


首先是,由于 JavaScript 与 WebAssembly 的对象绑定后、互相调用的性能很差,这大大限制了WebAssembly的适用范围,简单的将特定几个函数编译为 WebAssembly,然后交由 JavaScript 去调用的方式反而会因为频繁的互相操作反而造成性能下降。为了绕过这个问题,WebAssembly 设计了一组 API ,可以用于将一段 JavaScript ArrayBuffer 与 WebAssemly 中的字节流进行共享操作。所以白鹭引擎将所有对 WebAssembly 的调用封装为了一组字节流命令,并在用户逻辑全部执行完之后,将这个字节流命令传递给 WebAssembly,这样就大幅减少了 JavaScript 和 WebAssembly 之间的互操作。


其次是,由于 WebAssembly 不能直接操作 WebGL 等浏览器 API ,所以在每一帧对渲染内容进行完计算之后,需要把计算结果再保存在一段字节流中,共享给 JavaScript,交由 JavaScript 去操作DOM节点。由于最终仍然是 JavaScript 去操作DOM节点,必然仍然存在一定的性能问题。无法操作 DOM 节点使得目前 WebAssembly 无法完全代替掉 JavaScript。这一问题在 WebAssembly 的路线图中有所提及,会在未来的版本中加以解决。


因此可以看出,WebAssembly 适合将一段大量的、密集的逻辑计算抽象出来,统一一次性输入所有的参数、一次性返回所有的输出,比如游戏主渲染循环、物理引擎、粒子系统、骨骼动画计算等内容。


WebAssembly 的二进制格式可调试性较差


其次是可调试性,WebAssembly 被设计为了一种开放的、可调试的程序,但目前无论是 Chrome 还是 FireFox ,在调试方面还有很大的提升空间。由于在目前阶段调试较为困难,所以用 WebAssembly 编写业务逻辑代码对研发来说还是很不方便的。目前白鹭引擎的策略是把 Emscripten 中的 API 与业务逻辑进行隔离,通过C++自身的开发环境,剥离 Emscripten 进行独立的调试,然后再发布为 WebAssembly 格式,而非直接在浏览器端调试 WebAssembly。


虽然目前可调试性较差,但是我们相信这个问题在未来一定会得到较好的解决,同时,由于二进制的原因,代码体积很小,白鹭引擎团队将大约300k左右(压缩后)JavaScript 逻辑改用 WebAssembly 重写后,体积仅有90k左右。虽然使用 WebAssembly 需要引入一个50k-100k的JavaScript类库作为基础设施,但是总体来看资源尺寸的优势还是很大的。


由于代码格式是二进制、无法直接在浏览器中看到源码,尽管理论上仍然可以通过逆向工程一定程度上得到原有的业务逻辑,但是由于开发者可以在编译时使用了-O3 等激进的优化策略,所以最终反编译得到的业务逻辑也是很难阅读的。虽然理论上一切在客户端的内容都是不安全的,但是与所有代码都直接暴露给用户相比,代码安全性得到了很大的改善。


WebAssembly 的浏览器支持率仍很低


在当前,Chrome 57+ (包括PC与 Android),iOS 11 Safari 、FireFox 52 与 Microsoft Edge 均已支持 WebAssembly。但是仍然存在不稳定现象。以 Chrome 浏览器为例,Chrome 57 支持 WebAssembly 的 MVP 版本,但是在 Chrome 58 上,大量的 WebAssembly 程序会直接导致进程崩溃,虽然后续的 Chrome 59 已经修复了绝大部分问题,但是仍然不得不对目前版本的稳定性持保留态度。


在不支持 WebAssembly 的浏览器中,由于 C++代码在编译 WebAssemly 的同时也可以编译出完全符合 JavaScript语法的asm.js,所以可以保证业务逻辑是可以通过这种方式回退支持所有的浏览器。


WebAssembly 在移动设备上性能并没有跨越式提升


除此之外,笔者经过测试发现,在 PC Chrome 上,WebAssembly 相比 JavaScript 的性能有很大提升,但是在 Mobile Chrome 上,提升目前只有30%左右,这说明目前 WebAssembly 自身在性能挖掘上还有很大空间。


笔者运行了一个复杂的测试用例,15000个显示对象在屏幕上进行旋转,其测试结果如下:



从上性能测试可以看出,WebAssembly 比 JavaScript 版本以及 asm.js 版本均有一定提升。由于在测试Demo中,游戏逻辑(每一帧遍历15000个显示对象,修改其旋转属性)无论任何版本中均处于 JavaScript 环境运行。所以游戏逻辑的开销三种版本是一致的,而使用 WebAssembly 实现的渲染逻辑比 JavaScript 版本快30%以上。


在运行 benchmark 等极限测试时,游戏引擎使用 WebAssembly 并不比 JavaScript 有成倍的提升。笔者的推论是:由于 JavaScript 引擎的JIT机制会把经常运行的函数进行极限的编译优化,所以在 benchmark 这种代码大量反复执行的测试环境下,无论是 JavaScript 版本,还是 WebAssembly 版本,运行的都是高度优化后的机器码,虽然 WebAssembly 版本仍然比 JavaScript 版有一定的性能优势,但是并不明显。而在运行业务逻辑代码时,由于大部分业务逻辑代码只运行一次,所以 JavaScript 引擎只会 对这部分代码进行简单的编译优化而非极限优化,所以运行这一部分代码 WebAssembly 相比 JavaScript 版本而言提升巨大,但是因为上文所述,不建议开发者在编写业务逻辑时使用 WebAssembly,所以这里陷入了一个两难。在目前而言,理想情况是除了底层库之 外,部分关键的涉及性能问题的逻辑也可以使用 WebAssembly 进行编写。


结论


综上所述,目前为止由于 WebAssembly 还不是非常完善,所以它目前的主要作用是作为 JavaScript 生态的有益补充,与JavaScript共存而不是取而代之。但是通过其路线图我们可以 得知,WebAssembly 的设计思想非常优秀,目前所有存在的问题从长远的角度来说都是可以 解决的问题。在加上 WebAssembly 是非常罕见的由四大浏览器厂商共同宣布会大力支持并 实现的功能,其浏览器兼容性问题也终究可以得到解决,再退一步,哪怕旧式浏览器不支持, 由于 WebAssembly 支持回退到 JavaScript,也可以保证正常运行。


笔者认为,WebAssembly 就像当初的 HTML5 标准一样,在公布之后最开始不被很多人看 好,认为会有浏览器兼容性问题、各大浏览器厂商的实现问题、性能问题、用户需求与用户体验问题,但在近年来 HTML5 终于得到了广泛的使用,甚至有些人认为他可以在很多场景下取 代 NativeApp ,而非仅仅是当年“取代Flash”这一小目标。凭借着底层技术的跨越式发展, 以及浏览器厂商的一致支持,WebAssembly一定会有一个光明的未来。

编辑于 04-20