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 mentransfer grafis dari satu sistem ke sistem lainnya, termasuk saat mentransfer 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 project yang sudah ada, mungkin akan lebih mudah jika Anda menggunakan HTML Canvas API melalui sistem binding Emscripten Embind. Embind memungkinkan Anda beroperasi 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 cara melakukan transliterasi 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 untuk 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 di 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 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, tindakan 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 untuk mengaktifkannya 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 adalah library yang lebih disukai karena telah teruji dengan baik dan mendukung backend Emscripten secara resmi di upstream.

Menggambar persegi panjang

Bagian "Tentang SDL" di situs resmi menyebutkan:

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 ditransfer dan berfungsi dengan Emscripten di web juga sehingga Anda dapat membuat semua game yang dibuat dengan SDL2 tanpa banyak kesulitan. Jika Anda melakukan transfer project yang sudah ada, lihat bagian "Mengintegrasikan dengan sistem build" dalam dokumen Emscripten.

Agar lebih mudah, dalam postingan ini saya akan fokus pada kasus file tunggal 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 kotak 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 tertutup secara otomatis saat aplikasi telah menyelesaikan eksekusinya, sehingga gambar di kanvas 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 tertutup setelah keluar, sehingga pengguna tidak memiliki kesempatan untuk melihat gambar.

Mengintegrasikan loop peristiwa

Contoh yang lebih lengkap dan idiomatis tampaknya harus 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, tempatnya dapat memproses keyboard, mouse, dan peristiwa pengguna lainnya. Saat pengguna menutup jendela, dia akan memicu peristiwa SDL_QUIT, yang akan dicegat untuk keluar dari loop. Setelah loop ditutup, aplikasi akan melakukan pembersihan, lalu keluar secara otomatis.

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":

Singkatnya, browser menjalankan semua potongan kode dalam loop yang terus-menerus, dengan mengambilnya dari antrean satu per satu. Saat beberapa peristiwa dipicu, browser akan mengantrekan pengendali yang sesuai, dan pada iterasi loop berikutnya, pengendali akan diambil dari antrean dan dijalankan. Mekanisme ini memungkinkan simulasi konkurensi dan menjalankan banyak operasi paralel hanya dengan menggunakan satu thread.

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

Contoh sebelumnya mengeksekusi loop peristiwa tak terbatas, sedangkan kode itu sendiri berjalan di dalam loop peristiwa tak terbatas lainnya, yang secara implisit disediakan 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 untuk waktu minimum yang memungkinkan", 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 perlu dikompilasi dengan Asyncify diaktifkan:

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

Dan aplikasi tersebut kembali berfungsi seperti yang diharapkan di web:

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

Namun, Asyncify dapat memiliki overhead ukuran kode yang tidak umum. 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 harus diekstrak menjadi 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 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 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 atau 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 gerakan persegi panjang 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. 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. Misalnya, bisa 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 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 adalah hasil 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 pada kanvas persegi hitam.

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