将现有 C 模块编译到 WebAssembly

WebAssembly 的一个核心用例是利用现有的 C 库生态系统,让开发者可以在网页上使用它们。

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

例如,让我们将一个 WebP 编码器编译为 Wasm。WebP 编解码器的源代码是用 C 语言编写的,并在 GitHub 上提供,以及一些广泛的 API 文档。这是一个很好的起点。

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

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

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

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

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

要编译此程序,您需要告诉编译器它可以在哪里找到 libwebp 的头文件(使用 -I 标志),还需要传递它所需的 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 将当前版本 a.b.c 返回为十六进制数 0xabc。例如,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 中为图像分配内存,另一个在完成操作后释放内存。

cpp
#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 位。但这是一种保持代码简单的快捷方式。

cpp
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.