在 Emscripten 中在畫布上繪圖

瞭解如何使用 Emscripten 在網路上透過 WebAssembly 算繪 2D 圖形。

Ingvar Stepanyan
Ingvar Stepanyan

不同的作業系統有不同的繪圖 API。在撰寫跨平台程式碼或將圖形從一個系統移植到另一個系統時,這些差異會變得更加令人困惑,包括將原生程式碼移植到 WebAssembly 時。

在本篇文章中,您將瞭解幾種方法,可透過 Emscripten 編譯的 C 或 C++ 程式碼,在網站上的畫布元素上繪製 2D 圖形。

如果您要開始新的專案,而不是嘗試將現有專案移植,透過 Emscripten 的繫結系統 Embind 使用 HTML Canvas API 可能是最簡單的方法。Embind 可讓您直接操作任意 JavaScript 值。

如要瞭解如何使用 Embind,請先查看以下 MDN 的範例,該範例會尋找 <canvas> 元素,並在該元素上繪製一些形狀

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

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

以下說明如何使用 Embind 將其轉寫為 C++:

#include <emscripten/val.h>

using emscripten::val;

// Use thread_local when you want to retrieve & cache a global JS variable once per thread.
thread_local const val document = val::global("document");

// …

int main() {
  val canvas = document.call<val>("getElementById", "canvas");
  val ctx = canvas.call<val>("getContext", "2d");
  ctx.set("fillStyle", "green");
  ctx.call<void>("fillRect", 10, 10, 150, 100);
}

連結此程式碼時,請務必傳遞 --bind 來啟用 Embind:

emcc --bind example.cpp -o example.html

接著,您可以使用靜態伺服器提供已編譯的素材資源,並在瀏覽器中載入範例:

Emscripten 產生的 HTML 頁面,在黑色畫布上顯示綠色矩形。

選擇無框畫元素

使用 Emscripten 產生的 HTML 殼層搭配上述殼層指令時,系統會為您加入及設定畫布。這麼做可讓您更輕鬆地建構簡單的示範和範例,但在較大型應用程式中,您可能會想要在自己設計的 HTML 網頁中加入 Emscripten 產生的 JavaScript 和 WebAssembly。

系統產生的 JavaScript 程式碼會尋找儲存在 Module.canvas 屬性中的畫布元素。如同其他模組屬性,您可以在初始化期間設定此屬性。

如果您使用的是 ES6 模式 (將輸出內容設為具有擴充功能 .mjs 的路徑,或使用 -s EXPORT_ES6 設定),您可以這樣傳遞畫布:

import initModule from './emscripten-generated.mjs';

const Module = await initModule({
  canvas: document.getElementById('my-canvas')
});

如果您使用一般指令碼輸出內容,請先宣告 Module 物件,再載入 Emscripten 產生的 JavaScript 檔案:

<script>
var Module = {
  canvas: document.getElementById('my-canvas')
};
</script>
<script src="emscripten-generated.js"></script>

OpenGL 和 SDL2

OpenGL 是電腦圖形的熱門跨平台 API。在 Emscripten 中使用時,Emscripten 會負責將支援的 OpenGL 作業子集轉換為 WebGL。如果應用程式依賴 OpenGL ES 2.0 或 3.0 支援的功能,但不支援 WebGL,Emscripten 也可以模擬這些功能,但您必須透過相應的設定選擇加入。

您可以直接使用 OpenGL,也可以透過較高層級的 2D 和 3D 圖形程式庫使用。其中幾個已透過 Emscripten 移植到網頁。在這篇文章中,我將著重於 2D 圖形,而 SDL2 目前是首選程式庫,因為它經過充分測試,且支援 Emscripten 後端的官方上游。

繪製矩形

官方網站上的「關於 SDL」部分說明:

Simple DirectMedia Layer 是跨平台開發程式庫,可透過 OpenGL 和 Direct3D 提供音訊、鍵盤、滑鼠、搖桿和圖形硬體的低階存取權。

所有這些功能 (控制音訊、鍵盤、滑鼠和圖形) 都已移植,並可與網頁上的 Emscripten 搭配運作,因此您可以輕鬆移植使用 SDL2 建構的整個遊戲。如果您要移植現有專案,請參閱 Emscripten 說明文件中的「Integrating with a build system」一節。

為簡化說明,本文將著重於單一檔案的情況,並將先前的矩形範例轉譯為 SDL2:

#include <SDL2/SDL.h>

int main() {
  // Initialize SDL graphics subsystem.
  SDL_Init(SDL_INIT_VIDEO);

  // Initialize a 300x300 window and a renderer.
  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  // Set a color for drawing matching the earlier `ctx.fillStyle = "green"`.
  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  // Create and draw a rectangle like in the earlier `ctx.fillRect()`.
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);

  // Render everything from a buffer to the actual screen.
  SDL_RenderPresent(renderer);

  // TODO: cleanup
}

連結至 Emscripten 時,您必須使用 -s USE_SDL=2。這會告訴 Emscripten 擷取已預先編譯為 WebAssembly 的 SDL2 程式庫,並將其連結至主要應用程式。

emcc example.cpp -o example.html -s USE_SDL=2

在瀏覽器中載入範例後,您會看到熟悉的綠色矩形:

Emscripten 產生的 HTML 網頁,在黑色方形畫布上顯示綠色矩形。

不過,這個程式碼有幾個問題。首先,它無法妥善清理已分配的資源。其次,在網頁上,應用程式執行完畢後,網頁不會自動關閉,因此畫布上的圖片會保留。不過,如果同樣的程式碼以原生方式重新編譯,

clang example.cpp -o example -lSDL2

並執行時,建立的視窗只會短暫閃爍,並在退出時立即關閉,因此使用者無法看到圖片。

整合事件迴圈

更完整且符合慣例的範例會需要在事件迴圈中等待,直到使用者選擇退出應用程式為止:

#include <SDL2/SDL.h>

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  while (1) {
    SDL_Event event;
    SDL_PollEvent(&event);
    if (event.type == SDL_QUIT) {
      break;
    }
  }

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

將圖片繪製至視窗後,應用程式現在會在迴圈中等待,以便處理鍵盤、滑鼠和其他使用者事件。使用者關閉視窗時,系統會觸發 SDL_QUIT 事件,並攔截該事件以退出迴圈。迴圈結束後,應用程式會執行清理作業,然後自行結束。

在 Linux 上編譯這個範例後,程式會如預期般運作,並顯示 300 x 300 的視窗,其中含有綠色矩形:

黑色背景的方形 Linux 視窗,以及綠色矩形。

不過,這個範例無法在網路上運作。Emscripten 產生的網頁在載入期間立即凍結,且永遠不會顯示算繪圖片:

Emscripten 產生的 HTML 頁面上疊加「網頁無回應」錯誤對話方塊,建議使用者等待網頁回應或離開網頁

發生什麼事?我會引用「使用 WebAssembly 的非同步 Web API」一文中的答案:

簡單來說,瀏覽器會逐一從佇列中提取程式碼,以無限迴圈的方式執行所有程式碼。當某些事件觸發時,瀏覽器會將對應的處理常式排入佇列,並在下一個迴圈迭代時從佇列中取出並執行。這項機制可讓您只使用單一執行緒,同時模擬並行作業並執行大量並行作業。

關於這個機制,請務必記住,在自訂 JavaScript (或 WebAssembly) 程式碼執行時,事件迴圈會遭到封鎖 […]

先前的範例會執行無限的事件迴圈,而程式碼本身則會在瀏覽器隱含提供的另一個無限事件迴圈中執行。內迴圈永遠不會將控制權交給外迴圈,因此瀏覽器無法處理外部事件或在頁面上繪製內容。

解決這個問題的方法有兩種。

使用 Asyncify 解除事件迴圈的封鎖

首先,如連結文章所述,您可以使用 Asyncify。這是 Emscripten 功能,可讓您「暫停」C 或 C++ 程式、將控制權交還給事件迴圈,並在某些非同步作業完成時喚醒程式。

這類非同步作業甚至可以透過 emscripten_sleep(0) API 表示「休眠最短時間」。將控制項嵌入迴圈中間,可確保在每次迭代時將控制項傳回至瀏覽器的事件迴圈,讓網頁保持回應能力並處理任何事件:

#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  while (1) {
    SDL_Event event;
    SDL_PollEvent(&event);
    if (event.type == SDL_QUIT) {
      break;
    }
#ifdef __EMSCRIPTEN__
    emscripten_sleep(0);
#endif
  }

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

這段程式碼現在需要啟用 Asyncify 來編譯:

emcc example.cpp -o example.html -s USE_SDL=2 -s ASYNCIFY

應用程式又能正常在網頁上運作:

Emscripten 產生的 HTML 網頁,在黑色方形畫布上顯示綠色矩形。

不過,Asyncify 可能會產生不小的程式碼大小開銷。如果只用於應用程式中的頂層事件迴圈,建議使用 emscripten_set_main_loop 函式。

使用「main loop」API 解除事件迴圈的封鎖

emscripten_set_main_loop 不需要任何編譯器轉換作業來解開及倒轉呼叫堆疊,因此可避免程式碼大小的額外負擔。不過,在 Exchange 中,您需要手動修改更多程式碼。

首先,事件迴圈的主體必須擷取到獨立的函式中。接著,您需要呼叫 emscripten_set_main_loop,並在第一個引數中使用該函式做為回呼、在第二個引數中使用 FPS (0 為原生刷新間隔),以及在第三個引數中使用布林值 (true),用來指出是否要模擬無限迴圈:

emscripten_set_main_loop(callback, 0, true);

新建立的回呼無法存取 main 函式中的堆疊變數,因此 windowrenderer 這類變數必須要經由 API 的 emscripten_set_main_loop_arg 變化版本,將資料擷取到堆積分配的結構體和其指標,或是擷取到全域 static 變數 (為了簡化,我採用後者)。結果雖然較難理解,但繪製的矩形與上一個範例相同:

#include <SDL2/SDL.h>
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

由於所有控制流程變更都是手動完成,並反映在原始程式碼中,因此您可以再次編譯,而無需再使用 Asyncify 功能:

emcc example.cpp -o example.html -s USE_SDL=2

這個範例可能看起來沒什麼用處,因為它與第一個版本的運作方式並無二致,在第一個版本中,雖然程式碼簡單許多,但畫布上仍成功繪製了矩形,而且 SDL_QUIT 事件 (唯一在 handle_events 函式中處理的事件) 在網頁上會遭到忽略。

不過,如果您決定要加入任何類型的動畫或互動功能,則應採用適當的事件循環整合方式 (透過 Asyncify 或 emscripten_set_main_loop)。

處理使用者互動

舉例來說,您可以對上一個範例進行一些變更,讓矩形在回應鍵盤事件時移動:

#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};

void redraw() {
  SDL_SetRenderDrawColor(renderer, /* RGBA: black */ 0x00, 0x00, 0x00, 0xFF);
  SDL_RenderClear(renderer);
  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);
}

uint32_t ticksForNextKeyDown = 0;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  if (event.type == SDL_KEYDOWN) {
    uint32_t ticksNow = SDL_GetTicks();
    if (SDL_TICKS_PASSED(ticksNow, ticksForNextKeyDown)) {
      // Throttle keydown events for 10ms.
      ticksForNextKeyDown = ticksNow + 10;
      switch (event.key.keysym.sym) {
        case SDLK_UP:
          rect.y -= 1;
          break;
        case SDLK_DOWN:
          rect.y += 1;
          break;
        case SDLK_RIGHT:
          rect.x += 1;
          break;
        case SDLK_LEFT:
          rect.x -= 1;
          break;
      }
      redraw();
    }
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  redraw();
  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

使用 SDL2_gfx 繪製其他形狀

SDL2 會在單一 API 中抽象化跨平台差異和各種媒體裝置類型,但仍是相當低階的程式庫。特別是圖形,雖然它提供用於繪製點、線和矩形的 API,但實作任何更複雜的形狀和轉換,則由使用者自行決定。

SDL2_gfx 是填補這項缺口的獨立程式庫。舉例來說,您可以使用此方法,將上述範例中的矩形替換為圓形:

#include <SDL2/SDL.h>
#include <SDL2/SDL2_gfxPrimitives.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

SDL_Point center = {.x = 100, .y = 100};
const int radius = 100;

void redraw() {
  SDL_SetRenderDrawColor(renderer, /* RGBA: black */ 0x00, 0x00, 0x00, 0xFF);
  SDL_RenderClear(renderer);
  filledCircleRGBA(renderer, center.x, center.y, radius,
                   /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_RenderPresent(renderer);
}

uint32_t ticksForNextKeyDown = 0;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  if (event.type == SDL_KEYDOWN) {
    uint32_t ticksNow = SDL_GetTicks();
    if (SDL_TICKS_PASSED(ticksNow, ticksForNextKeyDown)) {
      // Throttle keydown events for 10ms.
      ticksForNextKeyDown = ticksNow + 10;
      switch (event.key.keysym.sym) {
        case SDLK_UP:
          center.y -= 1;
          break;
        case SDLK_DOWN:
          center.y += 1;
          break;
        case SDLK_RIGHT:
          center.x += 1;
          break;
        case SDLK_LEFT:
          center.x -= 1;
          break;
      }
      redraw();
    }
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  redraw();
  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

現在,您也需要將 SDL2_gfx 程式庫連結至應用程式。這項作業的執行方式與 SDL2 類似:

# Native version
$ clang example.cpp -o example -lSDL2 -lSDL2_gfx
# Web version
$ emcc --bind foo.cpp -o foo.html -s USE_SDL=2 -s USE_SDL_GFX=2

以下是 Linux 上執行的結果:

黑色背景的方形 Linux 視窗,以及綠色圓形。

使用網頁版時:

Emscripten 產生的 HTML 網頁,在黑色方形畫布上顯示綠色圓圈。

如要進一步瞭解其他圖形基本元素,請參閱自動產生的說明文件