在 Emscripten 中在畫布上繪圖

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

伊格瓦史坦尼恩
Ingvar Stepanyan

不同的作業系統有不同的繪製圖形 API。當您編寫跨平台程式碼或將圖形從某個系統移植至另一個系統時 (包括將原生程式碼移植到 WebAssembly),這些差異會更令人感到困惑。

在這篇文章中,您將瞭解使用 C 或 C++ 程式碼進行編譯的 C 或 C++ 程式碼,將 2D 圖形繪製到網路上的畫布元素的幾種方法。

透過 Embind 參觀帆布

如果您要開始進行新專案,而非嘗試移植現有的專案,最簡單的方法就是透過 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

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

由插圖產生的 HTML 網頁,顯示黑色畫布上的綠色矩形。

選擇畫布元素

搭配上述 shell 指令使用 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 時,系統會完成將支援的 OpenGL 作業子集轉換為 WebGL。如果您的應用程式需要使用 OpenGL ES 2.0 或 3.0 支援的功能,但未在 WebGL 中支援此功能,Escripten 也可以處理這些項目,但您必須透過相應設定選擇啟用。

您可以直接使用 OpenGL,或透過較高層級的 2D 和 3D 圖形程式庫使用 OpenGL。其中幾位已使用 Emscripten 轉移到網路。本文著重介紹 2D 圖形,由於 SDL2 已通過充分測試,且支援 Emscripten 後端正式上游,因此目前建議使用這個程式庫。

繪製矩形

官方網站上的「關於 SDL」部分指出:

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

所有這些功能 (控制音訊、鍵盤、滑鼠和圖形) 也都已移植,可在網路上與 Emscripten 搭配運作,讓您可以輕鬆將使用 SDL2 建構的整款遊戲輕鬆移植到網路上。如果您要移植現有專案,請參閱 Emscripten 文件的「整合建構系統」一節。

為求簡潔,在本文中,我將著重在單一檔案案例,並將先前的矩形範例轉譯為 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

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

由插圖產生的 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 產生的網頁會在載入期間立即停止運作,而且不會顯示轉譯的圖片:

由指令碼產生的 HTML 網頁重疊,以及「網頁無回應」錯誤對話方塊,建議等待網頁產生責任,或是離開頁面

這中間發生了什麼事?我將引用「使用來自 WebAssembly 的非同步網路 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

而且應用程式恢復上線後將繼續正常運作:

由插圖產生的 HTML 網頁,顯示黑色方形畫布上的綠色矩形。

不過,Asyncify 可能會造成輕微的程式碼大小負擔。如果只用於應用程式中的頂層事件迴圈,建議您使用 emscripten_set_main_loop 函式。

使用「主要迴圈」API 解除封鎖事件迴圈

emscripten_set_main_loop 不需要任何編譯器轉換,即可拆解並倒轉呼叫堆疊,因此可避免程式碼大小負擔。然而,為了換取另一個程式碼,必須另外手動修改程式碼。

首先,需要將事件迴圈的主體擷取至不同函式。接著,您需要使用該函式呼叫 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 視窗,背景是黑色背景,有綠色圓圈。

使用網頁版:

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

如要進一步瞭解圖形基元,請參閱自動產生的文件