Menggambar ke kanvas di Emscripten

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

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

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

Jika Anda memulai project baru, bukan mencoba melakukan port project yang sudah ada, sebaiknya gunakan Canvas API HTML melalui sistem binding Emscripten Embind. Embind memungkinkan Anda beroperasi langsung pada nilai JavaScript arbitrer.

Untuk memahami cara menggunakan Embind, lihat terlebih dahulu contoh dari MDN berikut yang menemukan elemen <kanvas>, dan menggambar beberapa bentuk di atasnya

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

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

Berikut cara mentransliterasikannya 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 menayangkan aset yang dikompilasi dengan server statis dan memuat contoh di browser:

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

Memilih elemen kanvas

Saat menggunakan shell HTML yang dibuat Emscripten dengan perintah shell sebelumnya, kanvas akan disertakan dan disiapkan untuk Anda. Hal ini mempermudah pembuatan demo dan contoh sederhana, tetapi dalam aplikasi yang lebih besar, Anda sebaiknya menyertakan JavaScript dan WebAssembly yang dibuat Emscripten di halaman HTML dari desain Anda sendiri.

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

Jika menggunakan mode ES6 (menetapkan 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 dibuat 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, library 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 melakukan emulasi fitur tersebut, tetapi Anda harus memilih ikut serta melalui setelan yang sesuai.

Anda dapat menggunakan OpenGL secara langsung atau melalui library grafis 2D dan 3D tingkat tinggi. Beberapa di antaranya telah di-port ke web dengan Emscripten. Dalam postingan ini, saya berfokus pada grafis 2D, dan untuk itu SDL2 saat ini adalah library yang lebih disukai karena telah diuji 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 level rendah ke hardware audio, keyboard, mouse, joystick, dan grafis melalui OpenGL dan Direct3D.

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

Untuk memudahkan, dalam postingan ini saya akan berfokus pada kasus satu file 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. Tindakan ini akan memberi tahu Emscripten untuk mengambil library SDL2, yang telah 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 persegi panjang hijau yang sudah dikenal:

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

Namun, kode ini memiliki beberapa masalah. Pertama, tidak ada pembersihan yang tepat dari resource yang dialokasikan. Kedua, di web, halaman tidak ditutup secara otomatis 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 dieksekusi, jendela yang dibuat hanya akan berkedip sebentar dan langsung ditutup setelah keluar, sehingga pengguna tidak memiliki kesempatan untuk melihat gambar.

Mengintegrasikan loop peristiwa

Contoh yang lebih lengkap dan idiomatis akan terlihat perlu menunggu dalam 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 loop, tempat aplikasi dapat memproses keyboard, mouse, dan peristiwa pengguna lainnya. Saat pengguna menutup jendela, mereka akan memicu peristiwa SDL_QUIT, yang akan dicegat untuk keluar dari loop. Setelah loop keluar, aplikasi akan melakukan pembersihan, lalu keluar dengan sendirinya.

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

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

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

Halaman HTML yang dibuat Emscripten yang ditempatkan dengan dialog error &#39;Halaman Tidak Responsif&#39; yang menyarankan untuk menunggu halaman menjadi responsif atau keluar dari halaman

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

Versi singkatnya adalah browser menjalankan semua bagian kode dalam semacam loop tanpa batas, dengan mengambilnya dari antrean satu per satu. Saat beberapa peristiwa dipicu, browser akan mengantrekan pengendali yang sesuai, dan pada iterasi loop berikutnya, pengendali akan dikeluarkan dari antrean dan dijalankan. 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 akan diblokir […]

Contoh sebelumnya mengeksekusi loop peristiwa tanpa batas, sedangkan kode itu sendiri berjalan di dalam loop peristiwa tanpa batas lainnya, yang disediakan secara implisit oleh browser. Loop dalam tidak pernah melepaskan kontrol ke loop luar, sehingga browser tidak mendapatkan kesempatan untuk memproses peristiwa eksternal atau menggambar sesuatu ke halaman.

Ada dua cara untuk memperbaiki masalah ini.

Membatalkan pemblokiran loop peristiwa dengan Asyncify

Pertama, seperti yang dijelaskan dalam artikel tertaut, Anda dapat menggunakan Asyncify. Ini adalah fitur Emscripten yang memungkinkan "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 ditampilkan 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 perlu 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 menampilkan persegi panjang hijau pada kanvas persegi hitam.

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

Membatalkan pemblokiran loop peristiwa dengan API "loop utama"

emscripten_set_main_loop tidak memerlukan transformasi compiler untuk memutar balik dan memutar ulang stack panggilan, sehingga menghindari overhead ukuran kode. Namun, sebagai gantinya, diperlukan lebih banyak modifikasi manual pada kode.

Pertama, isi loop peristiwa perlu diekstrak ke dalam fungsi terpisah. Kemudian, emscripten_set_main_loop harus dipanggil dengan fungsi tersebut sebagai callback di argumen pertama, FPS di argumen kedua (0 untuk interval refresh native), dan boolean yang menunjukkan apakah akan menyimulasikan loop tanpa batas (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 harus diekstrak ke dalam struct yang dialokasikan heap dan pointernya diteruskan melalui varian emscripten_set_main_loop_arg API, atau diekstrak ke dalam variabel static global (saya memilih yang terakhir untuk memudahkan). Hasilnya sedikit lebih sulit diikuti, tetapi akan 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 bersifat manual dan tercermin dalam kode sumber, kode tersebut 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, saat persegi panjang berhasil digambar di kanvas meskipun kodenya jauh lebih sederhana, dan peristiwa SDL_QUIT—satu-satunya yang ditangani dalam fungsi handle_events—diabaikan di web.

Namun, integrasi loop peristiwa yang tepat - baik melalui Asyncify maupun melalui 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 dapat 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 memisahkan perbedaan lintas platform dan berbagai jenis perangkat media dalam satu API, tetapi masih merupakan library tingkat rendah. Khusus untuk grafik, meskipun menyediakan API untuk menggambar titik, garis, dan persegi panjang, penerapan bentuk dan transformasi yang lebih kompleks diserahkan kepada pengguna.

SDL2_gfx adalah library terpisah yang mengisi kesenjangan tersebut. Misalnya, fungsi ini 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 perlu ditautkan ke aplikasi. Caranya sama seperti 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

Berikut adalah hasilnya yang berjalan di Linux:

Jendela Linux persegi dengan latar belakang hitam dan lingkaran hijau.

Dan di web:

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

Untuk primitif grafis lainnya, lihat dokumen yang dibuat otomatis.