Menggambar ke kanvas di Emscripten

Pelajari cara merender grafis 2D di web dari WebAssembly dengan Emscripten.

Sistem operasi yang berbeda memiliki API yang berbeda untuk menggambar grafik. Perbedaannya menjadi semakin membingungkan saat menulis kode lintas platform, atau melakukan porting grafis dari satu sistem ke sistem lainnya, termasuk saat mem-porting kode native ke WebAssembly.

Dalam posting ini Anda akan mempelajari beberapa metode untuk menggambar grafis 2D ke elemen kanvas di web dari kode C atau C++ yang dikompilasi dengan Emscripten.

Kanvas melalui Embind

Jika Anda memulai project baru daripada mencoba mem-port yang sudah ada, akan lebih mudah menggunakan Canvas API HTML melalui sistem binding Emscripten, Embind. Embind memungkinkan Anda beroperasi secara langsung pada nilai JavaScript arbitrer.

Untuk memahami cara menggunakan Embind, pertama-tama lihat contoh dari MDN berikut yang menemukan elemen <canvas>, dan menggambar beberapa bentuk di dalamnya

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

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

Berikut ini caranya dapat ditransliterasi ke C++ dengan Embind:

#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);
}

Saat menautkan kode ini, pastikan untuk meneruskan --bind guna mengaktifkan Embind:

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

Kemudian, Anda dapat menampilkan aset yang dikompilasi dengan server statis dan memuat contohnya di browser:

Halaman HTML yang dibuat Emscripten yang menampilkan persegi panjang hijau di atas kanvas hitam.

Memilih elemen kanvas

Jika menggunakan shell HTML yang dihasilkan Emscripten dengan perintah shell sebelumnya, kanvas akan disertakan dan disiapkan untuk Anda. Ini mempermudah pembuatan demo dan contoh sederhana, tetapi dalam aplikasi yang lebih besar, Anda perlu menyertakan JavaScript dan WebAssembly yang dibuat Emscripten pada halaman HTML desain Anda sendiri.

Kode JavaScript yang dihasilkan akan menemukan elemen kanvas yang disimpan di properti Module.canvas. Seperti properti Modul lainnya, properti ini dapat ditetapkan selama inisialisasi.

Jika Anda menggunakan mode ES6 (menyetel output ke jalur dengan ekstensi .mjs atau menggunakan setelan -s EXPORT_ES6), Anda dapat meneruskan kanvas seperti ini:

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

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

Jika menggunakan output skrip reguler, Anda harus mendeklarasikan objek Module sebelum memuat file JavaScript yang dihasilkan Emscripten:

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

OpenGL dan SDL2

OpenGL adalah API lintas platform yang populer untuk grafis komputer. Saat digunakan di Emscripten, fungsi ini akan menangani konversi subset operasi OpenGL yang didukung ke WebGL. Jika aplikasi Anda mengandalkan fitur yang didukung di OpenGL ES 2.0 atau 3.0, tetapi tidak di WebGL, Emscripten juga dapat menangani emulasinya, tetapi Anda harus ikut serta melalui setelan yang sesuai.

Anda dapat menggunakan OpenGL secara langsung atau melalui pustaka grafis 2D dan 3D di tingkat yang lebih tinggi. Beberapa di antaranya telah ditransfer ke web dengan Emscripten. Dalam postingan ini, saya berfokus pada grafis 2D, dan untuk itu, SDL2 saat ini adalah library pilihan karena telah teruji dengan baik dan mendukung backend Emscripten secara resmi di upstream.

Menggambar persegi panjang

Bagian "Tentang SDL" di situs resmi menyatakan:

Simple DirectMedia Layer adalah library pengembangan lintas platform yang dirancang untuk memberikan akses tingkat rendah ke audio, keyboard, mouse, joystick, dan hardware grafis melalui OpenGL dan Direct3D.

Semua fitur tersebut - mengontrol audio, keyboard, mouse, dan grafis - telah ditransfer dan berfungsi dengan Emscripten di web juga sehingga Anda dapat mem-port seluruh game yang dibuat dengan SDL2 tanpa banyak repot. Jika Anda mem-porting project yang sudah ada, lihat bagian "Mengintegrasikan dengan sistem build" pada dokumen Emscripten.

Untuk memudahkan, dalam posting ini saya akan fokus pada {i>single-file case<i} dan menerjemahkan contoh persegi panjang sebelumnya ke 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
}

Saat menautkan dengan Emscripten, Anda harus menggunakan -s USE_SDL=2. Ini akan memberi tahu Emscripten untuk mengambil library SDL2, yang sudah dikompilasi sebelumnya ke WebAssembly, dan menautkannya dengan aplikasi utama Anda.

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

Saat contoh dimuat di browser, Anda akan melihat kotak hijau yang sudah dikenal:

Halaman HTML yang dibuat Emscripten yang menampilkan persegi panjang hijau di kanvas persegi hitam.

Namun, kode ini memiliki beberapa masalah. Pertama, solusi ini tidak memiliki pembersihan yang tepat atas resource yang dialokasikan. Kedua, di web, halaman tidak otomatis ditutup saat aplikasi selesai dieksekusi, sehingga gambar di kanvas akan dipertahankan. Namun, saat kode yang sama dikompilasi ulang secara native dengan

clang example.cpp -o example -lSDL2

dan dijalankan, jendela yang dibuat hanya akan berkedip singkat dan langsung menutup setelah keluar, sehingga pengguna tidak memiliki kesempatan untuk melihat gambar itu.

Mengintegrasikan loop peristiwa

Contoh yang lebih lengkap dan idiomatis akan terlihat perlu menunggu loop peristiwa hingga pengguna memilih untuk keluar dari aplikasi:

#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();
}

Setelah gambar digambar ke jendela, aplikasi kini menunggu dalam sebuah loop, tempat aplikasi dapat memproses keyboard, mouse, dan event pengguna lainnya. Saat menutup jendela, pengguna akan memicu peristiwa SDL_QUIT, yang akan dicegat untuk keluar dari loop. Setelah loop keluar, aplikasi akan melakukan pembersihan dan keluar dari loop.

Sekarang, mengompilasi contoh ini di Linux berfungsi seperti yang diharapkan dan menampilkan jendela berukuran 300 x 300 dengan persegi panjang hijau:

Jendela Linux persegi dengan latar belakang hitam dan persegi panjang hijau.

Namun, contoh tersebut tidak lagi berfungsi di web. Halaman yang dibuat Emscripten langsung dibekukan selama pemuatan dan tidak pernah menampilkan gambar yang dirender:

Halaman HTML yang dibuat khusus yang terhampar dengan dialog error &#39;Halaman Tidak Responsif&#39; yang menyarankan untuk menunggu hingga halaman bertanggung jawab atau keluar dari halaman

Apa yang terjadi? Saya akan mengutip jawaban dari artikel "Using asinkron web API from WebAssembly":

Versi singkatnya adalah browser menjalankan semua bagian kode secara berurutan tanpa henti, dengan mengambilnya dari antrean satu per satu. Ketika peristiwa dipicu, browser akan mengantrekan pengendali yang sesuai, dan pada iterasi loop berikutnya, peristiwa tersebut akan diambil dari antrean dan dieksekusi. Mekanisme ini memungkinkan simulasi konkurensi dan menjalankan banyak operasi paralel hanya dengan menggunakan satu thread.

Hal penting yang perlu diingat tentang mekanisme ini adalah, saat kode JavaScript (atau WebAssembly) kustom Anda dieksekusi, loop peristiwa diblokir [...]

Contoh sebelumnya mengeksekusi loop peristiwa tak terbatas, sementara kode itu sendiri berjalan di dalam loop peristiwa tak terbatas lainnya, yang secara implisit disediakan oleh browser. Loop dalam tidak akan melepaskan kontrol ke loop luar, sehingga browser tidak memiliki kesempatan untuk memproses peristiwa eksternal atau menggambar sesuatu ke halaman.

Ada dua cara untuk mengatasi masalah ini.

Berhenti memblokir loop peristiwa dengan Asyncify

Pertama, seperti yang dijelaskan dalam artikel tertaut, Anda dapat menggunakan Asyncify. Ini adalah fitur Emscripten yang memungkinkan untuk "menjeda" program C atau C++, memberikan kontrol kembali ke loop peristiwa, dan mengaktifkan program saat beberapa operasi asinkron telah selesai.

Operasi asinkron tersebut bahkan dapat "tidur selama waktu minimum", yang dinyatakan melalui emscripten_sleep(0) API. Dengan menyematkannya di tengah loop, saya dapat memastikan bahwa kontrol dikembalikan ke loop peristiwa browser pada setiap iterasi, dan halaman tetap responsif serta dapat menangani peristiwa apa pun:

#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();
}

Kode ini sekarang harus dikompilasi dengan Asyncify diaktifkan:

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

Dan aplikasi berfungsi seperti yang diharapkan di web lagi:

Halaman HTML yang dibuat Emscripten yang menampilkan persegi panjang hijau di kanvas persegi hitam.

Namun, Asyncify dapat memiliki overhead ukuran kode yang sulit. Jika hanya digunakan untuk loop peristiwa tingkat atas dalam aplikasi, opsi yang lebih baik adalah menggunakan fungsi emscripten_set_main_loop.

Berhenti memblokir loop peristiwa dengan API "loop utama"

emscripten_set_main_loop tidak memerlukan transformasi compiler untuk melepas dan memundurkan stack panggilan, sehingga dengan demikian menghindari overhead ukuran kode. Namun, sebagai gantinya, diperlukan lebih banyak modifikasi manual pada kode.

Pertama, isi loop peristiwa harus diekstrak ke dalam fungsi terpisah. Kemudian, emscripten_set_main_loop perlu dipanggil dengan fungsi tersebut sebagai callback dalam argumen pertama, FPS di argumen kedua (0 untuk interval refresh native), dan boolean yang menunjukkan apakah akan menyimulasikan loop tak terbatas (true) di argumen ketiga:

emscripten_set_main_loop(callback, 0, true);

Callback yang baru dibuat tidak akan memiliki akses ke variabel stack dalam fungsi main, sehingga variabel seperti window dan renderer perlu diekstrak ke dalam struct dengan alokasi heap dan pointernya diteruskan melalui varian emscripten_set_main_loop_arg API, atau diekstrak ke variabel static global (saya menggunakan yang terakhir agar lebih mudah). Hasilnya sedikit lebih sulit untuk diikuti, tetapi menggambar persegi panjang yang sama dengan contoh terakhir:

#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();
}

Karena semua perubahan alur kontrol dilakukan secara manual dan tercermin dalam kode sumber, itu dapat dikompilasi tanpa fitur Asyncify lagi:

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

Contoh ini mungkin tampak tidak berguna karena cara kerjanya tidak berbeda dengan versi pertama, yaitu persegi panjang berhasil digambar di kanvas meskipun kodenya jauh lebih sederhana, dan peristiwa SDL_QUIT—satu-satunya yang ditangani dalam fungsi handle_events—tetap diabaikan di web.

Namun, integrasi loop peristiwa yang tepat - baik melalui Asyncify atau emscripten_set_main_loop - akan bermanfaat jika Anda memutuskan untuk menambahkan jenis animasi atau interaktivitas apa pun.

Menangani interaksi pengguna

Misalnya, dengan beberapa perubahan pada contoh terakhir, Anda bisa membuat persegi panjang bergerak sebagai respons terhadap peristiwa keyboard:

#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();
}

Menggambar bentuk lain dengan SDL2_gfx

SDL2 mengabstraksi perbedaan lintas platform dan berbagai jenis perangkat media dalam satu API, tetapi SDL2 masih merupakan library tingkat rendah. Khususnya untuk grafis, meskipun menyediakan API untuk menggambar titik, garis, dan persegi panjang, implementasi bentuk dan transformasi yang lebih kompleks diserahkan kepada pengguna.

SDL2_gfx adalah library terpisah yang mengisi kesenjangan tersebut. Sebagai contoh, dapat digunakan untuk mengganti persegi panjang dalam contoh di atas dengan lingkaran:

#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();
}

Sekarang, library SDL2_gfx juga harus ditautkan ke aplikasi. Langkah ini mirip dengan 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

Dan berikut ini hasil yang dijalankan di Linux:

Jendela Linux persegi dengan latar belakang hitam dan lingkaran hijau.

Dan di web:

Halaman HTML yang dibuat Emscripten yang menampilkan lingkaran hijau di kanvas persegi hitam.

Untuk mengetahui dasar grafis lainnya, lihat dokumen yang dibuat secara otomatis.