将现有 C 模块编译为 WebAssembly

WebAssembly 的一个核心用例是利用现有的 C 库生态系统,并允许开发者在 Web 上使用它们。

这些库通常依赖于 C 的标准库、操作系统、文件系统等。Emscripten 提供了这些功能的大部分,尽管存在一些 限制

例如,让我们将 WebP 的编码器编译为 Wasm。WebP 编解码器的源代码是用 C 编写的,并且可以在 GitHub 上找到,同时还有一些详尽的 API 文档。这是一个非常好的起点。

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

为了简单起见,让我们通过编写一个名为 webp.c 的 C 文件,将 encode.h 中的 WebPGetEncoderVersion() 暴露给 JavaScript。

c
#include "emscripten.h"
#include "src/webp/encode.h"

EMSCRIPTEN_KEEPALIVE
int version() {
    return WebPGetEncoderVersion();
}

这是一个很好的简单程序,可以测试您是否能成功编译 libwebp 的源代码,因为它不需要任何参数或复杂的数据结构来调用此函数。

要编译此程序,您需要使用 -I 标志告诉编译器 libwebp 的头文件在哪里,并且还需要传递它需要的所有 libwebp 的 C 文件。一个有用的策略是直接提供所有 C 文件,并依靠编译器剔除所有不必要的内容。对于这个库来说,这个策略似乎效果非常好。

bash
emcc -O3 -s WASM=1 -s EXPORTED_RUNTIME_METHODS='["cwrap"]' \
    -I libwebp \
    webp.c \
    libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c \
    libwebp/sharpyuv/*.c

注意:此策略并不适用于所有 C 项目。许多项目依赖于 autoconf/automake 在编译前生成系统特定的代码。Emscripten 提供了 emconfigureemmake 来包装这些命令并注入适当的参数。您可以在 Emscripten 文档 中找到更多信息。

现在,您只需要一些 HTML 和 JavaScript 来加载您的新模块。

html
<script src="./a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async () => {
    const api = {
      version: Module.cwrap("version", "number", []),
    };
    console.log(api.version());
  };
</script>

您将在 输出 中看到正确的版本号。

Screenshot of the DevTools console showing the correct version number.

注意: libwebp 以十六进制数 0xabc 的形式返回当前版本 a.b.c。例如,v0.6.1 被编码为 0x000601 = 1537。

将图像从 JavaScript 获取到 Wasm

获取编码器的版本号固然很好,但编码实际的图像会更令人印象深刻。我们该如何做到呢?

您需要回答的第一个问题是:如何将图像导入 Wasm?查看 libwebp 的 编码 API,您会发现它期望接收 RGB、RGBA、BGR 或 BGRA 格式的字节数组。幸运的是,Canvas API 提供了 CanvasRenderingContext2D.getImageData — 它会给您一个 Uint8ClampedArray,其中包含 RGBA 格式的图像数据。

js
async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement("canvas");
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext("2d");
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

现在,“只”剩下将数据从 JavaScript 复制到 Wasm 的问题了。为此,您需要暴露另外两个函数——一个用于在 Wasm 中为图像分配内存,另一个用于将其释放。

c
#include <stdlib.h> // required for malloc definition

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 图像的缓冲区 — 因此每像素 4 字节。malloc() 返回的指针是该缓冲区第一个内存单元的地址。当指针返回到 JavaScript 时,它被视为一个数字。使用 cwrap 将函数暴露给 JavaScript 后,您可以使用该数字找到缓冲区的起始位置并复制图像数据。

js
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);

编码图像

图像现在可以在 Wasm 中使用了。是时候调用 WebP 编码器来完成它的工作了。查看 WebP 文档,您会发现 WebPEncodeRGBA 似乎非常适合。该函数接收指向输入图像的指针及其尺寸,以及一个介于 0 和 100 之间的质量选项。它还会为我们分配一个输出缓冲区,完成后我们需要使用 WebPFree() 来释放它。

编码操作的结果是输出缓冲区及其长度。由于 C 中的函数不能返回数组类型(除非您动态分配内存),此示例求助于静态全局数组。这可能不是干净的 C。实际上,它依赖于 Wasm 指针的宽度为 32 位。但为了保持简单,这是一个合理的捷径。

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];
}

现在一切都准备就绪,您可以调用编码函数,获取指针和图像大小,将其放入您自己的 JavaScript 缓冲区中,并释放过程中分配的所有 Wasm 缓冲区。

js
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);

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

根据图像的大小,您可能会遇到一个错误,即 Wasm 无法分配足够的内存来同时容纳输入和输出图像。

Screenshot of the DevTools console showing an error.

幸运的是,这个问题的解决方案就在错误消息中。您只需要在编译命令中添加 -s ALLOW_MEMORY_GROWTH=1

至此,您已经成功编译了一个 WebP 编码器并将 JPEG 图像转码为 WebP。为了证明它有效,请将结果缓冲区转换为 Blob,并在 <img> 元素上使用它。

js
const blob = new Blob([result], { type: "image/webp" });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement("img");
img.src = blobURL;
img.alt = "a useful description";
document.body.appendChild(img);

看看新的 WebP 图像的辉煌吧。

演示 | 原始文章

DevTools network panel and the generated image.