最近,我将 Webots 的整个渲染引擎从 C++ 编译成了 WebAssembly,总共15,000 多行代码。我想通过本文分享我在这个过程中遇到的挑战、问题的解决方案,以及最终的结果。此次改进的效果十分可观!
首先,交代一下背景。Webots 是一个开源机器人模拟器。为了满足需求,Webots 拥有自己的渲染引擎:Wren(Webots Rendering Engine)。Wren 是用 C++ 编写的,而且依赖 OpenGL 3.3。但是,Wren 的公共 API 是用 C 编写的,这一点很重要,稍后我们会介绍原因。
此外,Webots 还支持将模拟的动画录制下来,或者进行直播。然后,你就可以在浏览器中查看生成的动画或直播了。之前,我们使用 Three.js 作为渲染引擎在 Web 上显示 Webots 模拟。Three.js 的运行良好,但它与 Wren 有一些本质上的区别,因此很难在桌面和 Web 上获得相同质量的图形显示。
经过一次彻底的分析后,我们决定将 Wren 移植到 WebAssembly。
为了将 C++ 代码编译成 WebAssembly,我使用了 Emscripten。Webots的 GitHub 代码库中包含该项目期间拉取请求生成的所有代码(https://github.com/cyberbotics/webots/pull/2769)。
通过下面的图片,你可以看出编译成 WebAssembly 后,Wren 的图形质量有了巨大的飞跃。
左:桌面版;
中:
Three.js 显示的 Web 版;
右:
编译成 WebAssembly 后的 Web 版
总体的规划
-
-
修改代码,解决编译到 WebAssembly 遇到的问题。
最后,我还会介绍一些我遇到的主要问题,并提供一些常见的建议。
第一步:准备代码
为了能够使用 WebAssembly 编译代码,首先我需要做一系列的准备。主要工作包括以下三项:
依赖关系
在使用 Emscripten 导出代码时,依赖关系很快就会变成一场噩梦。然而,我很幸运,Wren只有三个依赖项:OpenGL、glad 和 glm。
-
Emscripten 内置 OpenGL 的支持,但严格来说,只能支持 OpenGL 的子集 WebGL2。
-
glad 是一个 OpenGL 加载库,Emscripten 可以处理这部分,因此不需要在意。
-
glm 是一个只有头文件的数学库。我们可以使用 -I 选项将其包含到构建中。
由于上述原因,我几乎不需要担心依赖关系。但是,我仍然想提一下,因为我觉得如果你的代码有庞大的依赖关系,例如物理引擎,则依赖关系很可能会成为一个巨大的挑战。
修改头文件并排除有问题的函数
首先,我必须修改头文件,才能使用 Emscripten。在这一步中,大部分的修改都是将 glad 的头文件换成纯 OpenGL 头文件。
#ifdef __EMSCRIPTEN__
#include <GL/gl.h>
#include <GLES3/gl3.h>
#else
#include <glad/glad.h>
#endif
接下来,我排除了渲染引擎中的一些不兼容 Web 的函数。由于事先无法得知哪些函数不能用 Emscripten 编译,所以我只能反复试验。
-
一些无法用 Emscripten 编译到 WebGL2 的 OpenGL 函数。对于这一类函数,我只能暂时注释掉,然后等到第二步再解决。
-
一些需要读写磁盘的函数,例如加载字体。由于编译好的代码会放到 Web 上运行,因此 Emscripten 禁止访问磁盘。如果你需要从磁盘读取文件,则可以在链接时将其预加载到 Emscripten 提供的虚拟文件系统。我使用了这个虚拟文件系统来预加载所有的着色器。
准备导出函数和/或枚举
我必须在 Makefile 中设置一些正确的标志(主要是为了 OpenGL),这样就可以编译渲染引擎了。然而,事情远非这么简单。
问题是,用这种方法编译之后,我无法通过 JavaScript 访问 Wren 的任何功能。其背后原因是,Emscripten 在编译你提供的文件时,会将其作为可执行文件:一旦编译完成,启动该文件,就应该执行些什么。但是,这不是我想要的使用方式,我希望将其作为库来使用,我需要访问其中的各个函数。
幸运的是,这个问题有现成的解决方案。事实上,如果你使用 C,则有一种解决方案;如果使用 C++,则有两种解决方案。
C 的解决方案更为简单,实现速度也更快。你只需在链接时指定所有希望能够在 JavaScript 中使用的函数的名称。请不要忘记,必须在函数名称的开头添加下划线。
对于 C++,解决方法则略微复杂,你必须为每个函数/类编写一些类似于头文件的结构。我不打算在此详细讨论,因为我没有使用这个方法,更多信息请参见这里(https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html#embind)。
还记得我曾说过 Wren 的公共 API 是使用 C 语言编写的,这一点很重要。由于我可以使用 Emscripten 导出 C 的方法,因此节省了大量时间。
我有很多函数要导出(大约 330 个),因此我使用 pyclibrary 编写了一个 Python 脚本来解析所有头文件,并找出函数名称。脚本链接请参见这里(https://github.com/cyberbotics/webots/blob/master/scripts/export_function_js/export_function.py)。请注意,Emscripten 不提供导出 C 枚举的方法。因此,我的脚本还解析了所有的枚举,并将它们直接写入了 .js 文件。
-sEXPORTED_FUNCTIONS=’[$(shell cat functions_to_export.txt)]’
然后,我就可以从 JavaScript 访问每个函数了。
至此,我成功编译了整个渲染引擎。结果得到了三个文件:
-
包含WebAssembly 代码的 .wasm 文件。
-
一个 .js 文件,这是一个“胶水”文件,能够在WebAssembly 和你希望与之交互的其他 JavaScript 或 HTML 文件之间建立链接。
-
一个 .data 文件,其中包含我在 Emscripten 的虚拟文件系统中预加载的文件。
请注意,虽然我已经编译了渲染引擎,但不意味着没有任何问题。前路还很漫长……
第二步:修改代码
Webots使用了 Three.js,可在 Web 上运行,我的目标是使用 WebAssembly 编译 Wren。问题在于:Three.js 和 Wren 之间没有一对一的映射关系。所以,我不得不从头开始。首先,我建立了一个非常简单的概念证明:一个白色的立方体。然后,我在其之上构建了其他几何图形、外观、光照、阴影……
但文本主要讨论的是 WebAssembly 部分。
-
-
-
遇到一些由于从C++ 到 WebAssembly 的转换而引发的错误。
-
-
问题
指针
Wren的 C 接口中使用了大量的指针:函数接受指针参数,并返回指针。
而另一方面,JavaScript 中没有指针。为了在 Web 上使用 Wren,我必须编写它与 JavaScript 的接口。
如果将C 函数的返回值(一个指针)赋给一个 JavaScript 变量,会怎么样呢?JavaScript 会将其当成一个简单的整数。其实这样正合适,因为如果将这个整数传递给另一个接受指针的 C 函数,就能顺利地运行。
如果将 JavaScript 数组或对象作为参数,发送给需要指针的 C 函数,就会出现问题。C 函数会尽最大努力理解收到的数据,但大多数情况下都会失败。
例子
-
我有一个 C 函数,经过了 WebAssembly 的编译,可更改背景颜色。这个函数需要一个参数:constfloat* color。
-
在JavaScript 中,颜色存储在一个简单的 JavaScript 数组中。
-
虽然背景颜色已设置,但无论 JavaScript 数组中的值是什么,背景始终为红色。
发生这种情况,是因为 C 函数在努力翻译我传递过去的数组,最终它认为整个 JavaScript 结构为红色值。
如果你打算在 JavaScript 中使用 Emscripten 编译函数,那么可以通过这个案例学到一个重要的教训:小心没有任何错误和警告的问题!对于我遇到的这种情况,很明显什么地方出问题了,但你有可能会遇到不同的问题,有的时候甚至会让你抓狂!
幸运的是,有一些解决方案可以将 JavaScript 对象传递给 Emscripten。在这个项目中,我结合使用了以下三种方案:
1、使用 Emscripten 的方法 ccall 和 cwrap。为此,必须添加以下编译选项:
-s ‘EXPORTED_RUNTIME_METHODS=[“ccall”, “cwrap”]’
如下所示,我们可以使用 ccall 和 cwrap 调用一些C 函数,但需要指定参数的类型。这种方法非常适合处理字符串,但如果需要传递数组,那么这些函数的用途就会很有限。
Module.ccall('wr_post_processing_effect_pass_set_name', null,['number', 'string'], [colorPassTrough, "colorPassThrough"]);
请注意 this.previousInverseViewMatrixPointer,这是我们从另一个 C 函数获得的指针,我们将其定义为类型为 number 的指针。
2、对于数组,你可能需要使用 Emscripten 内置的 malloc 和 free 实现。这个方法稍微有点复杂:
var buf =Module._malloc(myTypedArray.length*myTypedArray.BYTES_PER_ELEMENT);
Module.HEAPU8.set(myTypedArray, buf);
Module.ccall('my_function', 'number', ['number'], [buf]);
Module._free(buf);
这段代码会分配一个缓冲区,填充,然后传递给 C 函数,然后再释放。
3、使用一个 C 静态函数作为过渡。我们再来看看上面的例子,我想将颜色向量传递给 C 函数。由于这个操作会频繁进行,所以我不想每一次都分配和释放缓冲区。另一种方法是设计一个额外的 C 函数,如下所示:
float *wrjs_array3(float element0, float element1, floatelement2) {
static float array[3];
array[0] = element0;
array[1] = element1;
array[2] = element2;
return array;
}
这个函数可以接收三个单独的元素(在这个例子中为颜色),并返回一个指向包含这些元素的静态数组的指针。然而,这种方式也有一些缺点。例如,仅适用于预定义大小的数组,并且一次只能有一个(颜色)数组。
OpenGL
这个问题的原因是 Webots 使用的是 OpenGL 3.3,而在 Web 上我们使用的是 WebGL2。WebGL2 是 OpenGL 3.3 的对应版本,但二者并不完全相同。此外,Emscripten 使用 OpenGL ES 3 编译函数,这与 OpenGL 3.3 也略有不同。
这意味着,WebGL2 中不包含部分 OpenGL 3.3 中的函数,或者 OpenGL3.3 和 WebGL2 中都存在的某个函数,却不包含在 OpenGL ES 3 中。或者三个版本都有某个函数,但接收的参数却不相同。
我没有找到通用的解决方案,如下是我使用过的一些技巧:
-
修改 C++ 代码,用不同的方式来实现相同的功能,同时能与 gcc(必须保证渲染引擎的桌面版能够正常运行)和 WebAssembly 兼容。
-
使用 EM_ASM,这是一种很方便的方法,我们可以直接用 C 编写 WebGL 代码。如果遇到某个 WebGL 2 的函数,而 OpenGL ES 3 没有,就可以使用这种方法。
-
发挥创造力。我直接重新实现了一些着色器或代码中不可用的函数,并利用 ifdef 为编译器 Emscripten 或 gcc 编写了不同的处理。
建议
-
尽量采用纯 C 语言编写的 API,C 版的 API 比 C++ 更容易导出。
-
见机行事,没有某个神奇的解决方案能够解决编译成 WebAssembly引入的所有问题。
-
小心没有任何错误和警告的问题,导出的函数会经常发生这种情况。
-
仔细管理指针,并仔细检查你提供给 WebAssembly 的数据是否得到了正确的解释。
-
结果
从 Three.js 到 WebAssembly,编译渲染引擎的转变极大地提高了 Web 模拟的图形质量。
通过以下图形可以看出桌面版、使用 Three.js 的 Web 版,以及使用 Emscripten 编译的渲染引擎的 Web 版之间的差异。
https://medium.com/cyberbotics/porting-a-c-rendering-engine-to-webassembly-9c32d76c31f1
☞