WebAssembly 该怎么学?

什么是 WebAssembly?

  • 一种新型的代码,可以运行在 Web 浏览器,提供一些新特性并主要专注于高性能

  • 主要不是用于写,而是 C/C++、C#、Rust 等语言编译的目标,所以你即使不知道如何编写 WebAssembly 代码也能利用它的优势

  • 其他语言编写的代码也能以近似于原生速度运行,客户端 App 也能在 Web 上运行

  • 在浏览器或 Node.js 中可以导入 WebAssembly 模块,JS 框架能够使用 WebAssembly 来获得巨大的性能优势和新的特性的同时在功能上易于使用

WebAssembly 的目标

  1. 快、高效、便利 -- 通过利用一些通用的硬件能力,能够跨平台以近乎于原生的速度执行

  2. 可读、可调试 -- WebAssembly 是一种低层次的汇编语言,但是它也有一种人类可读的文本格式,使得人们可编写代码、查看代码、可调试代码。

  3. 确保安全 -- WebAssembly 明确运行在安全、沙箱的执行环境,类似其他 Web 的代码,它会强制开启同源和一些权限策略。

  4. 不破坏现有的 Web -- WebAssembly 被设计与其他 Web 技术兼容运行,并且保持向后兼容性。

WebAssembly 如何与 Web 兼容的?

Web 平台可以看做有两个部分:

  1. 一个虚拟机(VM)用于运行 Web 应用代码,例如 JS 引擎运行 JS 代码

  2. 一系列 Web API,Web 应用可以调用这些 API 来控制 Web 浏览器/设备 的功能,来做某些事情(DOM、CSSOM、WebGL、IndexedDB、Web Audio API 等)

长期以来,VM 只能加载 JS 运行,JS 可能足够满足我们的需求,但如今我们却遇到了各种性能问题,如 3D 游戏、VR/AR、计算机视觉、图片/视频编辑、以及其他需要原生性能的领域。

同时,下载、解析和编译大体积的 JS 应用是很困难的,在一些资源更加受限的平台上,如移动设备等,则会更加放到这种性能瓶颈。

WebAssembly 是一种与 JavaScript 不同的语言,它不是为了替代 JS 而生的,而是被设计为与 JS 互为补充并能协作,使得 Web 开发者能够重复利用两种语言的优点:

  1. JS 是高层次的语言,灵活且极具表现力,动态类型、不需要编译步骤,并且有强大的生态,非常易于编写 Web 应用。

  2. WebAssembly 是一种低层次、类汇编的语言,使用一种紧凑的二级制格式,能够以近乎原生的性能运行,并提供了低层次的内存模型,是 C++、Rust 等语言的编译目标,使得这类语言编写的代码能够在 Web 上运行(需要注意的是,WebAssembly 将在未来提供垃圾回收的内存模型等高层次的目标)

随着 WebAssembly 的出现,上述提到的 VM 现在可以加载两种类型的代码执行:JavaScript 和 WebAssembly。

JavaScript 和 WebAssembly 可以互操作,实际上一份 WebAssembly 代码被称为一个模块,而 WebAssembly 的模块与 ES2015 的模块在具有很多共同的特性。

WebAssembly 的关键概念

为了理解 WebAssembly 是如何在 Web 运行的,需要了解几个关键概念:

  1. Module:通过浏览器编译成为可执行机器码的 WebAssembly 二进制文件,Module 是无状态的,类似 Blob,能够在 Window 和 Worker 之间通过 postMessage 共享,一个 Module 声明了类似 ES2015 模块类似的 import 和 export。

  2. Memory:一个可调整大小的 ArrayBuffer,其中包含由 WebAssembly 的低层次内存访问指令读取和写入的线性字节数组。

  3. Table:一个可调整大小的类型化引用数组(如函数),然而处于安全和可移植性的原因,不能作为原始字节存储在内存中

  4. Instance:一个包含它在运行时用到的所有状态,包含 Memory、Table、以及一系列导入值的 Module,一个 Instance 类似一个 ES2015 的模块,它被加载到具有特定导入集的特定全局变量中

WebAssembly 的 JavaScript API 提供给开发者创建 Module、Memory、Table 和 Instance 的能力,给定一个 WebAssembly 的 Instance,JS 代码可以同步的调用它的 exports -- 被作为普通的 JavaScript 函数导出。任意 JavaScript 函数可以被 WebAssembly 代码同步的调用,通过将 JavaScript 函数作为 imports 传给 WebAssembly Instance。

因为 JavaScript 能够完全控制 WebAssembly 代码的下载、编译和运行,所以 JavaScript 开发者可以认为 WebAssembly 只是 JavaScript 的一个新特性 -- 可以高效的生成高性能的函数。

在未来, WebAssembly 模块可以以 ES2015 的模块加载形式加载,如 <script type="module">,意味着 JS 可以获取、编译、和导入一个 WebAssembly 模块,就像导入 ES2015 模块一样简单。

如何在应用里使用 WebAssembly?

WebAssembly 给 Web 平台添加了两块内容:一种二进制格式代码,以及一系列可用于加载和执行二进制代码的 API。

WebAssembly 目前处于一个萌芽的节点,之后肯定会涌现出很多工具,而目前有四个主要的入口:

  • 使用 EMScripten 来移植 C/C++ 应用

  • 在汇编层面直接编写和生成 WebAssembly 代码

  • 编写 Rust 应用,然后将 WebAssembly 作为它的输出

  • 使用 AssemblyScript,它是一门类似 TypeScript 的语言,能够编译成 WebAssembly 二进制

移植 C/C++ 应用

虽然也有一些其他工具如:

  • WasmFiddle[1]

  • WasmFiddle++[2]

  • WasmExplorer[3]

但是这些工具都缺乏 EMScripten 的工具链和优化操作,EMScripten 的具体运行过程如下:

d3215d3565e071ee728f42245f64ee1f.png
  1. EMScripten 将 C/C++ 代码喂给 Clang 编译器(一个基于 LLVM 编译架构的 C/C++ 编译器),编译成 LLVM IR

  2. EMScripten 将 LLVM IR 转换成 .wasm 的二进制字节码

  3. WebAssembly 无法直接获取到 DOM,只能调用 JS,传入整形或浮点型的等原始数据类型,因此 WebAssembly 需要调用 JS 来获取 Web API 和调用,EMScripten 则通过创建了 HTML 文件和 JS 胶水代码来达到上述效果

未来 WebAssembly 也可以直接调用 Web API[4]

上述的 JS 胶水代码并不像想象中那么简单,一开始,EMScripten 实现了一些流行的 C/C++ 库,如 SDL、OpenGL、OpenAL、以及一部分 POSIX 库,这些库都是根据 Web API 来实现的,所以需要 JS 胶水代码来帮助 WebAssembly 和底层的 Web API 进行交互。

所以,有部分胶水代码实现了 C/C++ 代码需要用到的对应的库的功能,胶水代码还同时包含调用上述 WebAssembly JavaScript API 的以获取、加载和运行 .wasm 文件的逻辑。

生成的 HTML 文档加载 JS 胶水代码,然后将输出写入到 <textarea> 中去,如果应用使用到了 OpenGL,HTML 也包含 <canvas> 元素来作为渲染目标,你可以很方便的改写 EMScripten 的输出,将其转换成 Web 应用需要的形式。

直接编写 WebAssembly 代码

如果你想构建自己的编译器、工具链,或者能够在运行时生成 WebAssembly 代码的 JS 库,你可以选择手写 WebAssembly 代码。和物理汇编语言类似,WebAssembly 的二进制格式也有一种文本表示,你可以手动编写或生成这种文本格式,并通过 WebAssembly 的文本到二进制(text-to-binary)的工具将文本转为二进制格式。

编写 Rust 代码,并编译为 WebAssembly

多谢 Rust WebAssembly 工作组的不懈努力,我们现在可以将 Rust 代码编译为 WebAssembly 代码。

可以参考这个链接:https://developer.mozilla.org/en-US/docs/WebAssembly/Rust_to_wasm

使用 AssemblyScript

对于 Web 开发者来说,可是使用类 TypeScript 的形式来尝试 WebAssembly 的编写,而不需要学习 C 或 Rust 的细节,那么 AssemblyScript 将会是最好的选择。AssemblyScript 将 TypeScript 的变体编译为 WebAssembly,使得 Web 开发者可以使用 TypeScript 兼容的工具链,例如 Prettier、VSCode Intellisense,你可以查看它的文档[5]来了解如何使用。

如何编译将新写 C/C++ 代码编译到 WebAssembly?

通过 EMScripten 工具,可将新写的 C/C++ 代码编译为 WebAssembly 使用。

准备条件

为了能够使用 Emscripten 工具,我们需要安装它。首先 Clone 相关代码:

git clone https: // github . com / emscripten-core / emsdk . git

cd emsdk

然后执行如下脚本来配置 emsdk:

# 如果之前 clone 过,那么这里更新最新的代码

git pull



# 下载和安装最新的 SDK 工具

./emsdk install latest



# 为当前的 user 激活最新的 SDK 工具,在 .emscripten 文件中写入当前用户

./emsdk activate latest



# 将 SDK 相关的命令加入到 PATH,以及激活其他环境变量

source ./emsdk_env.sh

通过上面的操作我们就可以在命令行使用 Emscripten 相关的命令了,一般我们使用 Emscripten 时,主要有两种场景:

  • 编译成 WASM 然后创建 HTML 文档来运行代码,结合 JavaScript 胶水代码来在 Web 环境运行 wasm 代码

  • 编译成 wasm 代码,只创建 JavaScript 文件

生成 HTML 和 JavaScript

首先在 emsdk 目录同级创建一个文件夹:WebAssembly ,然后在文件夹下创建一份 C 代码:hello.c 如下:

#include <stdio.h>



int main() {

    printf("Hello World\n");

}

然后在命令行中导航到此 hello.c 目录下, 运行如下命令来调用 Emscripten 进行编译:

emcc hello.c -s WASM=1 -o hello.html

上述命令解释如下:

  • emcc 为 Emscripten 的命令行命令

  • -s WASM=1 则告诉 Emscripten 需要输出 wasm 文件,如果不指定这个参数,那么默认会输出 asm.js

  • -o hello.html 则告诉编译器生成一个名为 hello.html 的 HTML 文档来运行代码,以及 wasm 模块和对应的用于编译和实例化 wasm 的 JavaScript 胶水代码,以便 wasm 可以在 Web 环境中使用

运行如上命令之后,你的 WebAssembly 目录下应该多出了三个文件:

  • 二进制的 wasm 模块代码:hello.wasm

  • 包含胶水代码的 JavaScript 文件:hello.js ,通过它将原生 C 函数翻译成 JavaScript/wasm 代码

  • 一个 HTML 文件:hello.html ,用于加载、编译和实例化 wasm 的代码,并将 wasm 代码的输出展示在浏览器上。

运行代码

目前剩下的工作为在支持 WebAssembly 的浏览器中加载 hello.html 运行。

在 Firefox 52+、Chrome 57+ 和最小的 Opera 浏览器中默认支持,也可以通过在 Firefox 47+ 中的 about:config 开启 javascript.options.wasm 以及 Chrome 51+、Opera 38+ 中的 chrome://flags 来允许实验性的 WebAssembly 特效支持。

因为现代浏览器不支持 file:// 形式的 XHR 请求,所以在 HTML 中无法加载 .wasm 等相关的文件,所以为了能够看到效果,需要额外的本地服务器支持,可以通过运行如下命令:

npx serve .

npx 为 npm 在 5.2.0+ 之后推出的一个便捷执行 npm 命令的工具,如上述的 serve,在运行时首先检测本地是否存在,如果不存在则下载原创对应的包,并执行对应的命令,并且为一次性的操作,免除了先安装再允许,且需要暂用本地内存的操作。

WebAssembly 文件夹下运行一个本地 Web 服务器,然后打开 http://localhost:5000/hello.html 查看效果:

893ca586048cd480ec6f760842043e63.png

可以看到 我们在 C 代码里面编写的打印 Hello World 的代码,成功输出到了浏览器里,你也可以打开控制台看到对应的输出:

874d362012cbca6d3aae5ca042fffd60.png

恭喜你!你成功将一个 C 模块编译成了 WebAssembly,并将其运行在了浏览器中!

使用自定义的 HTML 模板

上述例子中是使用了 Emscripten 默认的 HTML 模板,但是很多场景下我们都需要用到自定义的 HTML 模板,如将 WebAssembly 整合到现有的项目中使用时,就需要自定义 HTML 模板,接下来我们了解一下如何使用自定义的 HTML 模板。

首先在 WebAssembly 目录下新建 hello2.c 文件,写入如下内容:

#include <stdio.h>



int main() {

    printf("Hello World\n");

}

在之前 clone 到本地的 emsdk 仓库代码中找到 shell_minimal.html 文件,将其复制到 WebAssembly 目录下的子文件夹 html_template 下(此文件夹需要新建),现在 WebAssembly 目录下的文件结构如下:

.

├── hello.c

├── hello.html

├── hello.js

├── hello.wasm

├── hello2.c

└── html_template

    └── shell_minimal.html

在命令行导航到 WebAssembly 下,运行如下命令:

emcc -o hello2.html hello2.c -O3 -s WASM=1 --shell-file html_template/shell_minimal.html

可以看到,相比之前在参数传递上有几点变化:

  • 通过设置 -o hello2.html ,编译器将会将输出 hello2.js 的 JS 胶水代码以及 hello2.html 的 HTML 文件

  • 同时设置了 --shell-file html_template/shell_minimal.html ,通过这个命令提供了你在生成 HTML 文件时使用的 HTML 模板地址。

现在让我们运行这个 HTML,通过如下命令:

npx serve .

在浏览器中导航到:localhosthttp://localhost:5000/hello2.html[6] 来访问运行结果,可以观测到和之前类似的效果:

ba5226be8428fe092c9930bc44b084b3.png

可以看到只是缺少了之前的 Emscripten 头部,其他都和之前类似,查看 WebAssembly 文件目录,会发现生成了类似的 JS、Wasm 代码:

.

├── hello.c

├── hello.html

├── hello.js

├── hello.wasm

├── hello2.c

├── hello2.html

├── hello2.js

├── hello2.wasm

└── html_template

    └── shell_minimal.html

注意:你可以指定只输出 JavaScript 胶水代码,而不是一份完整的 HTML 文档,通过在 -o 标签后面指定为 .js 文件,例如 emcc -o hello2.js hello2.c -O3 -s WASM=1 ,然后你可以自定义 HTML 文件,然后导入这份胶水代码使用,然而这是一种更加高级的方法,常用的形式还是使用提供的 HTML 模板:

  • Emscripten 需要大量的 JavaScript 胶水代码来处理内存分配,内存泄露以及一系列其他问题。

调用在 C 中自定义的函数

如果你在 C 代码里定义了一个函数,然后想在 JavaScript 中调用它,你可以使用 Emscripten 的 ccall 函数,以及 EMSCRIPTEN_KEEPALIVE 声明(这个声明将你的 C 函数加入到函数输出列表,具体的工作过程如下:

首先在 WebAssembly 目录下创建 hello3.c 文件,添加如下内容:

#include <stdio.h>

#include <emscripten/emscripten.h>



int main() {

    printf("Hello World\n");

}



#ifdef __cplusplus

extern "C" {

#endif



EMSCRIPTEN_KEEPALIVE void myFunction(int argc, char ** argv) {

    printf("MyFunction Called\n");

}



#ifdef __cplusplus

}

#endif

Emscripten 生成的代码默认只调用 main 函数,其他函数会作为 “死代码” 删除掉。在函数名之前加入 EMSCRIPTEN_KEEPALIVE 声明会阻止这种 “删除” 发生,你需要导入 emscripten.h 头文件来使用 EMSCRIPTEN_KEEPALIVE 声明。

注意我们在代码中添加了 #ifdef 块,确保在 C++ 代码中导入这个使用时也是可以正确工作的,因为 C 和 C++ 的命名可能存在一些混淆的规则,所以上述添加 EMSCRIPTEN_KEEPALIVE 声明的函数可能会失效,所以在 C++ 环境下为函数加上 external ,将其当做 external 函数,这样在 C++ 环境下也可以正确工作。

然后为了演示方便, HTML 文件照样使用我们之前放到 html_template 目录下的 shell_minimal.html 文件,然后使用如下命令编译 C 代码:

emcc -o hello3.html hello3.c -O3 -s WASM=1 --shell-file html_template/shell_minimal.html -s NO_EXIT_RUNTIME=1  -s "EXTRA_EXPORTED_RUNTIME_METHODS=['ccall']"

注意到在上述编译中,我们加上了 NO_EXIT_RUNTIME 参数,因为当 main 函数运行完之后,程序就会退出,所以加上这个参数确保其他函数还是还能如期运行。

而额外添加的 EXTRA_EXPORTED_RUNTIME_METHODS 则用于为 WebAssembly 的 Module 导出 ccall 方法使用,使得可以在 JavaScript 调用导出的 C 函数。

当你通过 npx serve . 运行时,依然可以看到类似之前的结果:

0bad9489100cfa91d0845e71ecd3ffe3.png

现在我们可以尝试在 JavaScript使用 myFunction 函数,首先在编辑器中打开 hello3.html 文件,然后添加一个 <button> 元素,并在 <button> 元素点击时能够调用 myFunction 函数:

<!-- 其他内容 --->

<button class="mybutton">Run myFunction</button>



<script type='text/javascript'>

// ... 其他生成的代码



// script 标签底部

document.querySelector('.mybutton')

    .addEventListener('click', function() {

        alert('check console');

        var result = Module.ccall(

            'myFunction',        // name of C function

            null,        // return type

            null,        // argument types

            null        // arguments

        );

    });

</script>



<!-- 其他内容 --->

保存上述内容,重新刷新浏览器可以看到如下结果:

465a70a9aea15535f72c78bf3ed8d4a8.png

当我们点击上图中的按钮时,可以获得如下结果:

c6d52638bd8c363cf132a05e8526701a.png 1d3c335fe0dd04d777afe2a5cbd57252.png

首先会收到一个 alert 提示,然后在输出里面打印了 MyFunction Called 内容,表示 myFunction 调用了,打开控制台也可以看到如下打印结果:

e36e27f6075e514a152c825f6081031c.png

上述例子展示了可以在 JavaScript 中通过 ccall 来调用 C 代码中导出的函数。

如何编译已经存在的 C 模块到 WebAssembly?

一个 WebAssembly 的核心使用场景就是将重复利用已经存在的 C 生态系统中的库,并将它们编译到 Web 平台上使用而不用重新实现一套代码。

这些 C 库通常依赖 C 的标准库,操作系统,文件系统或者其他依赖,Emscripten 提供绝大部分上述依赖的特性,尽管还是存在一些限制。

让我们将 C 库的 WebP 编码器编译到 wasm 来了解如何编译已经存在的 C 模块,WebP codec 的源码是用 C 实现的,能够在 Github[7] 上找到它,同时可以了解到它的一些 API 文档[8]

首先 Clone WebP 编码器的源码到本地,和 emsdkWebAssembly 目录同级:

git clone https://github.com/webmproject/libwebp

为了快速上手,我们可以先导出 encode.h 头文件里面的 WebPGetEncoderVersion 函数给到 JavaScript 使用,首先在 WebAssembly 文件夹下创建 webp.c 文件并加入如下:

#include "emscripten.h"

#include "src/webp/encode.h"



EMSCRIPTEN_KEEPALIVE

int version() {

  return WebPGetEncoderVersion();

}

上述的例子可以很快速的检验是否正确编译了 libwebp 的源码并能成功使用其函数,因为上述函数无需各种复杂的传参和数据结构即可成功执行。

为了编译上述函数,我们首先得告诉编译器如何找到 libwebp 库的头文件,通过在编译时加上标志 I ,然后指定 libwep 头文件的地址来告诉编译器地址,并将编译器所需要的所有 libwebp 里面的 C 文件都传给它。但有时候一个个列举 C 文件非常的繁琐,所以一种有效的策略就是将所有的 C 文件都传给编译器,然后依赖编译器自身去过滤掉那些不必要的文件,上述描述的操作可以通过在命令行编写如下命令实现:

emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \

 -I libwebp \

 WebAssembly/webp.c \

 libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

注意:上述的传参策略并不对在所有 C 项目都生效,有很多项目在编译前依赖 autoconfig/automake 等库来生成系统特定的代码,而 Emscripten 提供了 emconfigureemmake 来封装这些命令,并注入合适的参数来抹平那些有前置依赖的项目。

运行上述命令之后,会产出一份 a.out.js 胶水代码,和 a.out.wasm 文件,然后你需要在 a.out.js 文件输出的目录下创建一份 HTML 文件,并在其中添加如下代码

<script src="./a.out.js"></script>

<script>

  Module.onRuntimeInitialized = async _ => {

    const api = {

      version: Module.cwrap('version', 'number', []),

    };

    console.log(api.version());

  };

</script>

上述代码中,我们首先导入编译器编译输出的 a.out.js 胶水代码,然后在 WebAssembly 的模块初始化好了之后,通过 cwrap 函数导出 C 函数 version 使用,通过运行和之前类似的 npx serve . 命令,然后打开浏览器可以看到如下效果:

e7deb440bab572ea4b717644d34b90c2.png

libwebp 通过十六进制的 0xabc 的 abc 来表示当前版本 a.b.c ,例如 v0.6.1,则会被编码成十六进制 0x000601 ,对应的十进制为 1537。而这里为十进制 66049,转成 16 进制则为 0x010201 ,表示当前版本为 v1.2.1。

在 JavaScript 中获取图片并放入 wasm 中运行

刚刚通过调用编码器的 WebPGetEncoderVersion 方法来获取版本号来证实了已经成功编译了 libwebp 库到 wasm,然后可以在 JavaScript 使用它,接下来我们将了解更加复杂的操作,如何使用 libwebp 的编码 API 来转换图片格式。

libwebp 的 encoding API 需要接收一个关于 RGB、RGBA、BGR 或 BGRA 的字节数组,所以首先要回答的问题是,如何将图片放入 wasm 运行?幸运的是,Canvas API 有一个 CanvasRenderingContext2D.getImageData 方法,能够返回一个 Uint8ClampedArray ,这个数组包含 RGBA 格式的图片数据。

首先我们需要在 JavaScript 中编写加载图片的函数,将其写到上一步创建的 HTML 文件里:

<script src="./a.out.js"></script>

<script>

  Module.onRuntimeInitialized = async _ => {

    const api = {

      version: Module.cwrap('version', 'number', []),

    };

    console.log(api.version());

  };

  

   async function loadImage(src) {

     // 加载图片

      const imgBlob = await fetch(src).then(resp => resp.blob());

      const img = await createImageBitmap(imgBlob);

      

      // 设置 canvas 画布的大小与图片一致

      const canvas = document.createElement('canvas');

      canvas.width = img.width;

      canvas.height = img.height;

      

      // 将图片绘制到 canvas 上

      const ctx = canvas.getContext('2d');

      ctx.drawImage(img, 0, 0);

      return ctx.getImageData(0, 0, img.width, img.height);

    }

</script>

现在剩下的操作则是如何将图片数据从 JavaScript 复制到 wasm,为了达成这个目的,需要在先前的 webp.c 函数里面暴露额外的方法:

  • 一个为 wasm 里面的图片分配内存的方法

  • 一个释放内存的方法

修改 webp.c 如下:

#include <stdlib.h> // 此头文件导入用于分配内存的 malloc 方法和释放内存的 free 方法



EMSCRIPTEN_KEEPALIVE

uint8_t* create_buffer(int width, int height) {

  return malloc(width * height * 4 * sizeof(uint8_t));

}



EMSCRIPTEN_KEEPALIVE

void destroy_buffer(uint8_t* p) {

  free(p);

}

create_buffer 为 RGBA 的图片分配内存,RGBA 图片一个像素包含 4 个字节,所以代码中需要添加 4 * sizeof(uint8_t)malloc 函数返回的指针指向所分配内存的第一块内存单元地址,当这个指针返回给 JavaScript 使用时,会被当做一个简单的数字处理。当通过 cwrap 函数获取暴露给 JavaScript 的对应 C 函数时,可以使用这个指针数字找到复制图片数据的内存开始位置。

我们在 HTML 文件中添加额外的代码如下:

<script src="./a.out.js"></script>

<script>

  Module.onRuntimeInitialized = async _ => {    

    const api = {

      version: Module.cwrap('version', 'number', []),

      create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),

      destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),

      encode: Module.cwrap("encode", "", ["number","number","number","number",]),

      free_result: Module.cwrap("free_result", "", ["number"]),

      get_result_pointer: Module.cwrap("get_result_pointer", "number", []),

      get_result_size: Module.cwrap("get_result_size", "number", []),

    };

    

    const image = await loadImage('./image.jpg');

    const p = api.create_buffer(image.width, image.height);

    Module.HEAP8.set(image.data, p);

    

    // ... call encoder ...

    

    api.destroy_buffer(p);

  };

  

   async function loadImage(src) {

     // 加载图片

      const imgBlob = await fetch(src).then(resp => resp.blob());

      const img = await createImageBitmap(imgBlob);

      

      // 设置 canvas 画布的大小与图片一致

      const canvas = document.createElement('canvas');

      canvas.width = img.width;

      canvas.height = img.height;

      

      // 将图片绘制到 canvas 上

      const ctx = canvas.getContext('2d');

      ctx.drawImage(img, 0, 0);

      return ctx.getImageData(0, 0, img.width, img.height);

    }

</script>

可以看到上述代码除了导入之前添加的 create_bufferdestroy_buffer 外,还有很多用于编码文件等方面的函数,我们将在后续讲解,除此之外,代码首先加载了一份 image.jpg 的图片,然后调用 C 函数为此图片数据分配内存,并相应的拿到返回的指针传给 WebAssembly 的 Module.HEAP8 ,在内存开始位置 p,写入图片的数据,最后会释放分配的内存。

编码图片

现在图片数据已经加载进 wasm 的内存中,可以调用 libwebp 的 encoder 方法来完成编码过程了,通过查阅 WebP 的文档[9],发现可以使用 WebPEncodeRGBA 函数来完成工作。这个函数接收一个指向图片数据的指针以及它的尺寸,以及一个区间在 0-100 的可选的质量参数。在编码的过程中,WebPEncodeRGBA 会分配一块用于输出数据的内存,我们需要在编码完成之后调用 WebPFree 来释放这块内存。

我们打开 webp.c 文件,添加如下处理编码的代码:

int result[2];

EMSCRIPTEN_KEEPALIVE

void encode(uint8_t* img_in, int width, int height, float quality) {

  uint8_t* img_out;

  size_t size;



  size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);



  result[0] = (int)img_out;

  result[1] = size;

}



EMSCRIPTEN_KEEPALIVE

void free_result(uint8_t* result) {

  WebPFree(result);

}



EMSCRIPTEN_KEEPALIVE

int get_result_pointer() {

  return result[0];

}



EMSCRIPTEN_KEEPALIVE

int get_result_size() {

  return result[1];

}

上述 WebPEncodeRGBA 函数执行的结果为分配一块输出数据的内存以及返回内存的大小。因为 C 函数无法使用数组作为返回值(除非我们需要进行动态内存分配),所以我们使用一个全局静态数组来获取返回的结果,这可能不是很规范的 C 代码写法,同时它要求 wasm 指针为 32 比特长,但是为了简单起见我们可以暂时容忍这种做法。

现在 C 侧的相关逻辑已经编写完毕,可以在 JavaScript 侧调用编码函数,获取图片数据的指针和图片所占用的内存大小,将这份数据保存到 JavaScript 自己的内存中,然后释放 wasm 在处理图片时所分配的内存,让我们打开 HTML 文件完成上述描述的逻辑:

<script src="./a.out.js"></script>

<script>

  Module.onRuntimeInitialized = async _ => {    

    const api = {

      version: Module.cwrap('version', 'number', []),

      create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),

      destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),

      encode: Module.cwrap("encode", "", ["number","number","number","number",]),

      free_result: Module.cwrap("free_result", "", ["number"]),

      get_result_pointer: Module.cwrap("get_result_pointer", "number", []),

      get_result_size: Module.cwrap("get_result_size", "number", []),

    };

    

    const image = await loadImage('./image.jpg');

    const p = api.create_buffer(image.width, image.height);

    Module.HEAP8.set(image.data, p);

    

    api.encode(p, image.width, image.height, 100);

    const resultPointer = api.get_result_pointer();

    const resultSize = api.get_result_size();

    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);

    const result = new Uint8Array(resultView);

    api.free_result(resultPointer);

    

    api.destroy_buffer(p);

  };

  

   async function loadImage(src) {

     // 加载图片

      const imgBlob = await fetch(src).then(resp => resp.blob());

      const img = await createImageBitmap(imgBlob);

      

      // 设置 canvas 画布的大小与图片一致

      const canvas = document.createElement('canvas');

      canvas.width = img.width;

      canvas.height = img.height;

      

      // 将图片绘制到 canvas 上

      const ctx = canvas.getContext('2d');

      ctx.drawImage(img, 0, 0);

      return ctx.getImageData(0, 0, img.width, img.height);

    }

</script>

在上述代码中我们通过 loadImage 函数加载了一张本地的 image.jpg 图片,你需要事先准备一张图片放置在 emcc 编译器输出的目录下,也就是我们的 HTML 文件目录下使用。

注意:new Uint8Array(someBuffer) 将会在同样的内存块上创建一个新视图,而 new Uint8Array(someTypedArray) 只会复制 someTypedArray 的数据。

当你的图片比较大时,因为 wasm 不能扩充可以容纳 inputoutput 图片数据的内存,你可能会遇到如下报错:

9e4897bc310ac23bb3831d7a6f43dcae.png

但是我们例子中使用的图片比较小,所以只需要单纯的在编译时加上一个过滤参数 -s ALLOW_MEMORY_GROWTH=1 忽略这个报错信息即可:

emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \

    -I libwebp \

    test-dir/webp.c \

    libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c \

    -s ALLOW_MEMORY_GROWTH=1

再次运行上述命令,得到添加了编码函数的 wasm 代码和对应的 JavaScript 胶水代码,这样当我们打开 HTML 文件时,它已经能够将一份 JPG 文件编码成 WebP 的格式,为了近一步证实这个观点,我们可以将图片展示到 Web 界面上,通过修改 HTML 文件,添加如下代码:

<script>

  // ...

    api.encode(p, image.width, image.height, 100);

    const resultPointer = api.get_result_pointer();

    const resultSize = api.get_result_size();

    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);

    const result = new Uint8Array(resultView);

    

    // 添加到这里

    const blob = new Blob([result], {type: 'image/webp'});

    const blobURL = URL.createObjectURL(blob);

    const img = document.createElement('img');

    img.src = blobURL;

    document.body.appendChild(img)

    

    api.free_result(resultPointer);

    

    api.destroy_buffer(p);

</script>

然后刷新浏览器,应该可以看到WebP图片展示到 Web 端,通过将这个文件下载到本地,可以看到其格式转成了 WebP:

17d3117e88196dc6c9eb6b11d444f8e5.png

通过上述的流程我们成功编译了现有的 libwebp C 库到 wasm 使用,并将 JPG 图片转成了 WebP 格式并展示在 Web 界面上,通过 wasm 来处理计算密集型的转码操作可以大大提高网页的性能,这也是 WebAssembly 带来的主要优势之一。

本文为 WebAssembly 学习的第一篇文章,后续还会有一篇更加深入的文章,敬请期待!

参考资料

[1]

WasmFiddle: https://wasdk.github.io/WasmFiddle/

[2]

WasmFiddle++: https://anonyco.github.io/WasmFiddlePlusPlus/

[3]

WasmExplorer: https://mbebenita.github.io/WasmExplorer/

[4]

直接调用 Web API: https://github.com/WebAssembly/gc/blob/master/README.md

[5]

它的文档: https://www.assemblyscript.org/

[6]

localhosthttp://localhost:5000/hello2.html: http://localhost:5000/hello2.html

[7]

Github: https://github.com/webmproject/libwebp

[8]

API 文档: https://developers.google.com/speed/webp/docs/api

[9]

WebP 的文档: https://developers.google.com/speed/webp/docs/api#simple_encoding_api

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值