瞭解如何使用 Emscripten 從 WebAssembly 算繪網路上的 2D 圖形。
不同的作業系統有不同的繪圖 API。編寫跨平台程式碼或將圖像從某個系統移植到另一個系統時 (包括將原生程式碼移植到 WebAssembly),這項差異會更造成混淆。
在這篇文章中,您將瞭解幾種透過 C 或 Emscripten 編譯而成的 C++ 程式碼,為網路上的畫布元素繪製 2D 圖像的方法。
透過 Embind 繪製畫布
如果您要建立新專案,而非嘗試轉移現有專案,最簡單的方式就是透過 Emscripten 的繫結系統 Embind 使用 HTML Canvas API。Embind 可讓您直接操作任意 JavaScript 值。
如要瞭解如何使用 Embind,請先參閱以下範例中,找到 <canvas> 的 MDN 範例並在其上繪製一些形狀
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
接著,您就能透過靜態伺服器提供已編譯的資產,並在瀏覽器中載入範例:
選擇畫布元素
搭配上述 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 不支援這項功能,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 擷取 SDL2 程式庫、已預先編譯至 WebAssembly,並連結至主要應用程式。
emcc example.cpp -o example.html -s USE_SDL=2
瀏覽器載入範例後,您會看到熟悉的綠色矩形:
不過,這個程式碼有幾個問題。首先,它缺少適當清理分配的資源。其次,在網頁上,系統不會在應用程式執行完畢後自動關閉網頁,因此可保留畫布上的圖片。不過,如果使用
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 和綠色矩形的視窗:
不過,此範例已無法在網頁上發布。Emscripten 產生的網頁在載入期間會立即凍結,從不顯示轉譯的圖片:
發生什麼事?我將引述「透過 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
應用程式在網路上也能照常運作:
不過,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
函式中的堆疊變數,因此必須將 window
和 renderer
等變數擷取至堆積分配的結構體,並透過 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 上執行的結果:
使用網路:
如需更多圖形基本功能,請參閱自動產生的文件。