将 15000 行代码从 C++ 编译为 WebAssembly,是种怎样的体验?

将 15000 行代码从 C++ 编译为 WebAssembly,是种怎样的体验?
2021年09月17日 18:46 CSDN

作者|BenjaminDeleze

译者 | 弯月   责编 | 王晓曼

出品 | CSDN(ID:CSDNnews)

最近,我将 Webots 的整个渲染引擎从 C++ 编译成了 WebAssembly,总共15,000 多行代码。我想通过本文分享我在这个过程中遇到的挑战、问题的解决方案,以及最终的结果。此次改进的效果十分可观!

CSDN付费下载自东方IC

首先,交代一下背景。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 版

总体的规划

我将项目分成了两个主要部分:

为使用 Emscripten 编译代码做好准备;

修改代码,解决编译到 WebAssembly 遇到的问题。

最后,我还会介绍一些我遇到的主要问题,并提供一些常见的建议。

第一步:准备代码

为了能够使用 WebAssembly 编译代码,首先我需要做一系列的准备。主要工作包括以下三项:

尽可能减少依赖项的数量。

修改代码。

为导出函数和/或枚举做准备。

依赖关系

在使用 Emscripten 导出代码时,依赖关系很快就会变成一场噩梦。然而,我很幸运,Wren只有三个依赖项:OpenGL、glad 和 glm。

Emscripten 内置 OpenGL 的支持,但严格来说,只能支持 OpenGL 的子集 WebGL2。

glad 是一个 OpenGL 加载库,Emscripten 可以处理这部分,因此不需要在意。

glm 是一个只有头文件的数学库。我们可以使用 -I 选项将其包含到构建中。

由于上述原因,我几乎不需要担心依赖关系。但是,我仍然想提一下,因为我觉得如果你的代码有庞大的依赖关系,例如物理引擎,则依赖关系很可能会成为一个巨大的挑战。

修改头文件并排除有问题的函数

首先,我必须修改头文件,才能使用 Emscripten。在这一步中,大部分的修改都是将 glad 的头文件换成纯 OpenGL 头文件。

#ifdef __EMSCRIPTEN__

#include

#include

#else

#include

#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 的转换而引发的错误。

修复错误。

重复第一步。

下面,我们来谈一谈我所遇到的问题。

问题

我遇到的绝大多数问题可分为以下两类:

指针问题。

OpenGL 的问题。

指针

Wren的 C 接口中使用了大量的指针:函数接受指针参数,并返回指针。

而另一方面,JavaScript 中没有指针。为了在 Web 上使用 Wren,我必须编写它与 JavaScript 的接口。

如果将C 函数的返回值(一个指针)赋给一个 JavaScript 变量,会怎么样呢?JavaScript 会将其当成一个简单的整数。其实这样正合适,因为如果将这个整数传递给另一个接受指针的 C 函数,就能顺利地运行。

如果将 JavaScript 数组或对象作为参数,发送给需要指针的 C 函数,就会出现问题。C 函数会尽最大努力理解收到的数据,但大多数情况下都会失败。

例子

我在项目开始时遇到了如下案例:

我有一个 C 函数,经过了 WebAssembly 的编译,可更改背景颜色。这个函数需要一个参数:constfloat* color。

在JavaScript 中,颜色存储在一个简单的 JavaScript 数组中。

我直接将这个数组传给了 C 函数。

我可以加载网页,而且没有任何错误和警告。

虽然背景颜色已设置,但无论 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 的数据是否得到了正确的解释。

财经自媒体联盟更多自媒体作者

新浪首页 语音播报 相关新闻 返回顶部