本文已参与「新人创作礼」活动,一起开启掘金创作之路。
问题场景
在Web3D开发当中,我们面临这样一些问题:团队中已经有成熟的算法模块,通常使用C/C++编写,我们需要实现同样的功能,如何快速实现功能,并且保持功能的一致性和长期的可维护性。
技术选型
- 一方面我们可以使用前端Js的各类数学运算库如:math.gl和gpu.js,对已有的算法模块进行Js版本的重新实现
- 使用WebAssembly,通过辅助的编译工具如emscripten将已有的算法模块编译为前端可直接调用的模块
考虑到我们已经有成熟的C++算法模块,此时如果使用方案1,会带来较大的人力成本,并且在开发过程中,并不能确保逻辑的一致性,因此使用选用WebAssembly
实现
根据emscripten的官方教程进行emscripten的安装,再进行cmake的安装
基于Emscripten,有两种实现方案:
- emcc直接命令行编译,适用于依赖较少的情况,这次我们遇到的场景只需要用到Eigen,并且Eigen只需要以头文件的形式引入
- emcmake编译 适用于依赖较多的情况,如使用Eigen,OpenCV做一些图像处理时,推荐编写CMakeFile,维护起来更方便
下面对这两种编译方式进行介绍,读者可按照上述两种方式按需选择
使用emcc
emcc -I ./eigen/ main.cpp -o main.js -s EXPORTED_FUNCTIONS="['_solve','_free']" -s WASM=1 -s EXPORTED_RUNTIME_METHODS="['setValue','getValue']"
上述是编译Eigen算法模块的emcc命令,解释一下其中参数的含义,-I指gcc需要包含的头文件路径,main.cpp是主cpp文件,-o指输出,同时可以指定gcc优化级别,如果要使用wabt进行查看,就不要使用O3了
-s指options:
EXPORTED_FUNCTIONS:指希望导出的函数,需要加下划线
EXPORTED_RUNTIME_METHODS :指需要导出的Js runtime函数,根据使用的emscripten版本不同,并非所有的情况下,胶水JS代码都能完美的导出所有C++模块需要用到的函数,这个时候就需要用到wabt工具进行调试,没有的函数需要补充上,github链接附在最后
使用cmake
cmake_minimum_required(VERSION 2.8)
project(STEREO)
set(THIRDPARTY ${CMAKE_SOURCE_DIR}/../ThirdParty)
#wasm编译指令
set(EMSCRIPTENOPTIONS "SHELL:-s EXPORTED_FUNCTIONS=['_TriangulateDLTNView','_test'] -s EXPORTED_RUNTIME_METHODS=['ccall','cwrap'] --no-entry")
set(CMAKE_BUILD_TYPE Release)
set(CMAKE_CXX_FLAGS_RELEASE "-O1")
set(CMAKE_CXX_STANDARD 17)
#配置Eigen
include_directories(${THIRDPARTY}/Eigen)
add_executable(STEREO main.cpp)
target_link_options(STEREO PRIVATE ${EMSCRIPTENOPTIONS})
需要注意的是,最后的target_link_options,在这里可以链接上所需的emscripten的编译参数 编译成功后我们会得到:
其中main.wasm编译出的wasm文件,main.js是胶水js代码,main.js中默认通过fetch的方式进行加载,我们可以直接在html中引入
完成编译以后,进行Js与wasm模块的数据传递(函数传参),官方推荐使用arraybuffer进行传参,这里再介绍一种方式,
JS参数,传入到cpp(wasm)中
function getPoint(arr)
{
const BYTES=8;
const point = Module._malloc(arr.length * BYTES);
for(let i=0;i<arr.length;i++)
{
Module.setValue(point+i*BYTES, arr[i], 'double')
}
return point;
}
可以将js中的数据块,以指针的形式传入到cpp中
cpp计算结果,JS读取:
我们定义的函数:
const char* solve(int rayLength, double* raw){}
在Js中定义函数Pointer_stringify
function Pointer_stringify(ptr, length) {
if (length === 0 || !ptr) return '';
// TODO: use TextDecoder
// Find the length, and check for UTF while doing so
var hasUtf = 0;
var t;
var i = 0;
while (1) {
assert(ptr + i < TOTAL_MEMORY);
t = HEAPU8[(((ptr)+(i))>>0)];
hasUtf |= t;
if (t == 0 && !length) break;
i++;
if (length && i == length) break;
}
if (!length) length = i;
var ret = '';
if (hasUtf < 128) {
var MAX_CHUNK = 1024; // split up into chunks, because .apply on a huge string can overflow the stack
var curr;
while (length > 0) {
curr = String.fromCharCode.apply(String, HEAPU8.subarray(ptr, ptr + Math.min(length, MAX_CHUNK)));
ret = ret ? ret + curr : curr;
ptr += MAX_CHUNK;
length -= MAX_CHUNK;
}
return ret;
}
return Module['UTF8ToString'](ptr);
}
注意,在17年后,emscirpten默认取消导出了上述函数,因此上述函数不会默认存在于胶水JS文件中,Module对象是Js胶水代码中定义在全局上的,我们可以通过这个函数完成。
调用函数
最后我们调用:我们导出的solve函数
const rawPoint = getPoint(data)
const targetPoint = _solve(2, rawPoint);
const result = Pointer_stringify(targetPoint);
第一行的rawPoint就是JS数据在js和wasm共享内存中的指针,传到solve函数中,最后取指针按指针地址顺序读取数据得到最后结果
总结
在面临此类问题的时候,团队在做技术选型的时候,需要考虑一下几个问题,再决定是否需要使用WebAssembly:
- 开发成本:团队中是否有人力进行WebAssembly的开发,开发工作由前端承担还是由后端算法自己维护,如果由前者承担,在市面上相关技术人员并不充足的情况下,要保持好技术沉淀,梳理出一套较为通用的编译流程,对于大多数团队而言,如果由后端算法承担,需要让开发人员理解前端模块化知识,通常直接编译出的Js胶水文件可能需要修改,在定义函数,指针传参等知识点上,算法同学也有一些理解成本,团队应该权衡考虑。
- 场景选择,在做技术选型的时候,应该首先明确功能是否应该由前端承担,对于一些高性能计算的C++算法模块,如果用户的场景是用完即走,则用户通常对WebAPP的性能要求较高,此时不适合在前端做一些很重的计算,让用户等待太久,如在前端用PCL库做耗时较高的重建并不是什么明智的选择,首先应当考虑的还是在服务端计算,网络传输结果;对于一些工具类的,如三维家这种一次加载,长时使用的,使用WebAssembly去移植已有的CAD系统,显然是一种很不错的选择。
wasm调试工具wabt :github.com/WebAssembly… 可以通过wabt对编译出的wasm文件进行调试,可以看到导出了哪些函数