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.

Canvas 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 secara langsung pada nilai JavaScript arbitrer.

Untuk memahami cara menggunakan Embind, pertama-tama lihat contoh dari MDN berikut yang menemukan <canvas> , 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 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 menyajikan aset yang dikompilasi dengan server statis dan memuat contoh tersebut di browser:

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

Memilih elemen kanvas

Saat menggunakan shell HTML yang dihasilkan Emscripten dengan perintah shell sebelumnya, kanvas disertakan dan disiapkan untuk Anda. Hal ini mempermudah pembuatan demo dan contoh sederhana, tetapi dalam aplikasi yang lebih besar sebaiknya Anda menyertakan JavaScript dan WebAssembly yang dihasilkan oleh Emscripten pada laman HTML desain Anda sendiri.

Kode JavaScript yang dihasilkan mengharapkan untuk 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 yang lebih tinggi. Beberapa di antaranya telah ditransfer 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

"Tentang SDL" di situs resmi menyatakan:

Simple DirectMedia Layer adalah library pengembangan lintas platform yang dirancang untuk menyediakan 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 membuat semua game yang dibuat dengan SDL2 tanpa banyak kesulitan. Jika Anda mentransfer 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 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 dihasilkan dengan emskrip yang menampilkan persegi panjang hijau pada kanvas persegi hitam.

Namun, kode ini memiliki beberapa masalah. Pertama, tidak ada pembersihan yang tepat atas sumber daya yang dialokasikan. Kedua, di web, halaman tidak tertutup secara otomatis saat aplikasi selesai dijalankan, sehingga gambar di kanvas tetap dipertahankan. Namun, ketika 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 berukuran 300 x 300 dengan kotak hijau:

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

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

Halaman HTML yang dibuat dengan emscripten yang dihamparkan dengan &#39;Halaman Tidak Responsif&#39; dialog error yang menyarankan untuk menunggu halaman menjadi bertanggung jawab 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 tidak terbatas, dengan mengambilnya dari antrean satu per satu. Ketika beberapa peristiwa dipicu, browser akan mengantrekan pengendali yang sesuai, dan pada iterasi loop berikutnya, peristiwa tersebut diambil dari antrean dan dieksekusi. 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 mengatasi masalah ini.

Berhenti memblokir loop peristiwa dengan Asyncify

Pertama, seperti yang dijelaskan dalam artikel tertaut, Anda dapat menggunakan Asyncify. Ini adalah fitur {i>Emscripten<i} yang memungkinkan untuk "menjeda" program C atau C++, memberikan kontrol kembali ke loop peristiwa, dan mengaktifkan program saat beberapa operasi asinkron 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 harus dikompilasi dengan Asyncify yang 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 dihasilkan dengan emskrip yang menampilkan persegi panjang hijau pada kanvas persegi hitam.

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

Membatalkan pemblokiran loop peristiwa dengan "loop utama" API

emscripten_set_main_loop tidak memerlukan transformasi compiler apa pun untuk melepas 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 perlu dipanggil dengan fungsi tersebut sebagai callback di argumen pertama, FPS di argumen kedua (0 untuk interval pembaruan 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 harus diekstrak ke dalam struct dengan alokasi heap dan pointer yang diteruskan melalui varian emscripten_set_main_loop_arg API, atau diekstrak ke dalam 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 bersifat manual dan tercermin dalam kode sumber, perubahan ini 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, ketika persegi panjang berhasil digambar di kanvas meskipun kodenya jauh lebih sederhana, dan peristiwa SDL_QUIT—satu-satunya yang ditangani dalam fungsi handle_events—akan tetap 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 mengabstraksikan perbedaan lintas platform dan berbagai jenis perangkat media dalam satu API, tetapi library ini 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 harus ditautkan ke aplikasi. Ini dilakukan dengan cara yang 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

Dan berikut ini hasil yang berjalan di Linux:

Jendela Linux persegi dengan latar belakang hitam dan lingkaran hijau.

Dan di web:

Halaman HTML yang dibuat dengan emskrip yang menampilkan lingkaran hijau pada kanvas persegi hitam.

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