將現有 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.