Emscripten'da kanvasa çizim

Emscripten ile WebAssembly'den web'de 2D grafikleri nasıl oluşturacağınızı öğrenin.

Ingvar Stepanyan
Ingvar Stepanyan

Farklı işletim sistemlerinde grafik çizmek için farklı API'ler kullanılır. Platformlar arası kod yazarken veya grafikleri bir sistemden diğerine taşırken (yerel kodu WebAssembly'e taşıma dahil) bu farklar daha da kafa karıştırıcı hale gelir.

Bu yayında, Emscripten ile derlenmiş C veya C++ kodundan web'deki tuval öğesine 2D grafik çizmek için birkaç yöntem öğreneceksiniz.

Embind aracılığıyla Canvas

Mevcut bir projeyi taşımak yerine yeni bir proje başlatıyorsanız Emscripten'in bağlama sistemi Embind aracılığıyla HTML Canvas API'yi kullanmak en kolay seçenek olabilir. Embind, doğrudan rastgele JavaScript değerleri üzerinde işlem yapmanıza olanak tanır.

Embind'in nasıl kullanılacağını anlamak için önce bir <canvas> öğesi bulup üzerine bazı şekiller çizen MDN'deki aşağıdaki örneğe göz atın.

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

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

Embind ile C++'ya nasıl transliterasyon yapılabileceği aşağıda açıklanmıştır:

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

Bu kodu bağlarken, Embind'i etkinleştirmek için --bind parametresini ilettiğinizden emin olun:

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

Ardından, derlenmiş öğeleri statik bir sunucuda yayınlayabilir ve örneği bir tarayıcıya yükleyebilirsiniz:

Siyah bir kanvasta yeşil bir dikdörtgen gösteren, Emscripten tarafından oluşturulan HTML sayfası.

Tuval öğesini seçme

Emscripten tarafından oluşturulan HTML kabuğunu önceki kabuk komutuyla kullandığınızda tuval dahil edilir ve sizin için ayarlanır. Bu yöntem, basit demolar ve örnekler oluşturmayı kolaylaştırır ancak daha büyük uygulamalarda Emscripten tarafından oluşturulan JavaScript ve WebAssembly'i kendi tasarımınıza ait bir HTML sayfasına dahil etmek istersiniz.

Oluşturulan JavaScript kodu, Module.canvas mülkünde depolanan kanvas öğesini bulmayı bekler. Diğer modül özellikleri gibi, bu özellik de ilk başlatma sırasında ayarlanabilir.

ES6 modunu kullanıyorsanız (çıktıyı .mjs uzantılı bir yola ayarlayarak veya -s EXPORT_ES6 ayarını kullanarak) kanvası şu şekilde iletebilirsiniz:

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

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

Normal komut dosyası çıkışı kullanıyorsanız Emscripten tarafından oluşturulan JavaScript dosyasını yüklemeden önce Module nesnesini tanımlamanız gerekir:

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

OpenGL ve SDL2

OpenGL, bilgisayar grafikleri için popüler bir platformlar arası API'dir. Emscripten'de kullanıldığında, desteklenen OpenGL işlemlerinin alt kümesini WebGL'ye dönüştürür. Uygulamanız OpenGL ES 2.0 veya 3.0'ta desteklenen ancak WebGL'de desteklenmeyen özelliklere dayanıyorsa Emscripten bu özellikleri de taklit edebilir ancak ilgili ayarlar üzerinden etkinleştirmeniz gerekir.

OpenGL'i doğrudan veya daha üst düzey 2D ve 3D grafik kitaplıkları aracılığıyla kullanabilirsiniz. Bunlardan birkaçı Emscripten ile web'e taşındı. Bu yayında 2D grafiklere odaklanıyorum. Bu nedenle, iyi test edilmiş ve Emscripten arka ucunu resmi olarak desteklediği için şu anda tercih edilen kitaplık SDL2.

Dikdörtgen çizme

Resmi web sitesindeki "SDL hakkında" bölümünde şunlar belirtiliyor:

Simple DirectMedia Layer, OpenGL ve Direct3D üzerinden ses, klavye, fare, kontrol çubuğu ve grafik donanımlarına düşük seviye erişim sağlamak için tasarlanmış platformlar arası bir geliştirme kitaplığıdır.

Ses, klavye, fare ve grafikleri kontrol etme gibi tüm bu özellikler, web'de Emscripten ile birlikte çalışacak şekilde taşındı. Böylece, SDL2 ile oluşturulan oyunların tamamını çok fazla sorun yaşamadan taşıyabilirsiniz. Mevcut bir projeyi taşıyorsanız Emscripten dokümanlarının "Bir derleme sistemiyle entegrasyon" bölümüne göz atın.

Basitlik açısından bu yayında tek dosyalı bir duruma odaklanacağım ve önceki dikdörtgen örneğini SDL2'ye çevireceğim:

#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 ile bağlantı oluştururken -s USE_SDL=2 kullanmanız gerekir. Bu, Emscripten'e önceden WebAssembly olarak derlenmiş SDL2 kitaplığını getirmesini ve ana uygulamanıza bağlamasını söyler.

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

Örnek tarayıcıya yüklendiğinde tanıdık yeşil dikdörtgeni görürsünüz:

Siyah kare kanvasta yeşil bir dikdörtgen gösteren, Emscripten tarafından oluşturulan HTML sayfası.

Ancak bu kodda birkaç sorun var. İlk olarak, ayrılan kaynakların düzgün şekilde temizlenmemesi. İkinci olarak, web'de bir uygulamanın çalışması tamamlandığında sayfalar otomatik olarak kapanmaz. Bu nedenle, tuvaldeki resim korunur. Ancak aynı kod, doğal olarak yeniden derlendiğinde

clang example.cpp -o example -lSDL2

ve çalıştırılırsa oluşturulan pencere yalnızca kısa bir süre yanıp söner ve kullanıcının resmi görme şansı olmadığı için hemen kapanır.

Etkinlik döngüsü entegrasyonu

Daha kapsamlı ve doğal bir örnek, kullanıcı uygulamadan çıkmayı seçene kadar bir etkinlik döngüsünde beklemeyi gerektirir:

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

Resim bir pencereye çizildikten sonra uygulama, klavye, fare ve diğer kullanıcı etkinliklerini işleyebileceği bir döngüde bekler. Kullanıcı pencereyi kapattığında bir SDL_QUIT etkinliği tetikler. Bu etkinlik, döngüden çıkmak için müdahale edilir. Döngüden çıkıldığında uygulama temizliği yapar ve ardından kendisi de çıkar.

Artık bu örneği Linux'da derlemek beklendiği gibi çalışıyor ve yeşil bir dikdörtgen içeren 300x300 boyutunda bir pencere gösteriliyor:

Siyah arka planlı ve yeşil dikdörtgen içeren kare bir Linux penceresi.

Ancak bu örnek artık web'de çalışmıyor. Emscripten tarafından oluşturulan sayfa, yükleme sırasında hemen donar ve oluşturulan resmi hiçbir zaman göstermez:

Emscripten tarafından oluşturulan HTML sayfasının üzerine sayfanın yanıt vermesini beklemeyi veya sayfadan çıkmayı öneren bir &quot;Sayfa Yanıt Vermiyor&quot; hata iletişim kutusu yerleştirilmiş.

Ne oldu? "WebAssembly'den asenkron web API'lerini kullanma" makalesindeki yanıtı aktaracağım:

Kısaca, tarayıcı tüm kod parçalarını kuyruktan tek tek alarak bir tür sonsuz döngüde çalıştırır. Bir etkinlik tetiklendiğinde tarayıcı, ilgili işleyiciyi sıraya alır ve bir sonraki döngü iterasyonunda işleyici sıradan alınıp yürütülür. Bu mekanizma, yalnızca tek bir iş parçacığı kullanırken eşzamanlılığı simüle etmenize ve çok sayıda paralel işlem yürütmenize olanak tanır.

Bu mekanizmayla ilgili akılda tutulması gereken önemli nokta, özel JavaScript (veya WebAssembly) kodunuz yürütülürken etkinlik döngüsünün engellenmesidir. […]

Önceki örnekte sonsuz bir etkinlik döngüsü yürütülürken kodun kendisi, tarayıcı tarafından dolaylı olarak sağlanan başka bir sonsuz etkinlik döngüsü içinde çalışır. İç döngü, kontrolü hiçbir zaman dış döngüye bırakmaz. Bu nedenle tarayıcı, harici etkinlikleri işleme veya sayfaya bir şeyler çizme şansı bulamaz.

Bu sorunu çözmenin iki yolu vardır.

Asyncify ile etkinlik döngüsünün engellemesini kaldırma

Öncelikle, bağlı makalede açıklandığı gibi Asyncify'i kullanabilirsiniz. C veya C++ programını "duraklatmaya", kontrolü etkinlik döngüsüne geri vermeye ve bazı asenkron işlemler tamamlandığında programı uyandırmaya olanak tanıyan bir Emscripten özelliğidir.

Bu tür eşzamansız işlemler, emscripten_sleep(0) API aracılığıyla ifade edilen "mümkün olan en az süre boyunca bekleme" şeklinde bile olabilir. Bu işlevi döngünün ortasına yerleştirerek, her iterasyonda kontrolün tarayıcı etkinliği döngüsüne döndürülmesini ve sayfanın duyarlı kalmasını ve tüm etkinlikleri işleyebilmesini sağlayabilirim:

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

Bu kodun Asyncify etkinken derlenmesi gerekir:

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

Uygulama, web'de tekrar beklendiği gibi çalışıyor:

Siyah kare kanvasta yeşil bir dikdörtgen gösteren, Emscripten tarafından oluşturulan HTML sayfası.

Ancak Asyncify, kod boyutunda önemli bir ek yüke neden olabilir. Yalnızca uygulamadaki üst düzey bir etkinlik döngüsü için kullanılıyorsa emscripten_set_main_loop işlevini kullanmak daha iyi bir seçenek olabilir.

"Ana döngü" API'leriyle etkinlik döngüsünün engellemesini kaldırma

emscripten_set_main_loop, çağrı yığınını sarmalamak ve geri sarmak için herhangi bir derleyici dönüşümü gerektirmez. Bu sayede kod boyutu yükü önlenir. Ancak bunun karşılığında kodda çok daha fazla manuel değişiklik yapılması gerekir.

Öncelikle, etkinlik döngüsünün gövdesinin ayrı bir işleve ayıklanması gerekir. Ardından, emscripten_set_main_loop işlevinin ilk bağımsız değişkende geri çağırma işlevi, ikinci bağımsız değişkende FPS (yerel yenileme aralığı için 0) ve üçüncü bağımsız değişkende sonsuz döngünün simülasyona tabi tutulup tutulmayacağını belirten bir boole değeri ile çağrılması gerekir:true

emscripten_set_main_loop(callback, 0, true);

Yeni oluşturulan geri çağırma işlevi, main işlevindeki yığın değişkenlerine erişemez. Bu nedenle, window ve renderer gibi değişkenlerin bir yığına ayrılmış yapıya ayıklanması ve işaretçisinin API'nin emscripten_set_main_loop_arg varyantı üzerinden geçirilmesi veya küresel static değişkenlerine ayıklanması gerekir (basitlik açısından ikincisini tercih ettim). Sonuç biraz daha zor anlaşılır ancak son örnekle aynı dikdörtgeni çizer:

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

Tüm kontrol akışı değişiklikleri manuel olarak yapılır ve kaynak koda yansıtılır. Bu nedenle, kod tekrar Asyncify özelliği olmadan derlenebilir:

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

Bu örnek, kod çok daha basit olmasına rağmen dikdörtgenin tuval üzerine başarıyla çizildiği ve handle_events işlevinde işlenen tek olay olan SDL_QUIT etkinliğinin web'de zaten yoksayıldığı ilk sürümden farklı çalışmadığı için işe yaramaz görünebilir.

Ancak herhangi bir animasyon veya etkileşim eklemeye karar verirseniz Asyncify veya emscripten_set_main_loop aracılığıyla uygun etkinlik döngüsü entegrasyonunu kullanmak faydalı olacaktır.

Kullanıcı etkileşimlerini işleme

Örneğin, son örnekte birkaç değişiklik yaparak dikdörtgenin klavye etkinliklerine yanıt olarak hareket etmesini sağlayabilirsiniz:

#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 ile diğer şekilleri çizme

SDL2, platformlar arası farklılıkları ve çeşitli medya cihazı türlerini tek bir API'de soyutlar ancak yine de oldukça düşük düzey bir kitaplıktır. Özellikle grafikler için nokta, çizgi ve dikdörtgen çizme API'leri sağlarken daha karmaşık şekillerin ve dönüşümlerin uygulanması kullanıcıya bırakılır.

SDL2_gfx, bu boşluğu dolduran ayrı bir kitaplıktır. Örneğin, yukarıdaki örnekteki dikdörtgeni daireyle değiştirmek için kullanılabilir:

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

Artık SDL2_gfx kitaplığının da uygulamaya bağlanması gerekiyor. Bu işlem SDL2'ye benzer şekilde yapılır:

# 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'da elde edilen sonuçlar ise şöyle:

Siyah arka planlı ve yeşil bir dairenin yer aldığı kare bir Linux penceresi.

Web'de:

Siyah kare kanvasta yeşil bir daire gösteren, Emscripten tarafından oluşturulan HTML sayfası.

Daha fazla grafik ilkel öğesi için otomatik olarak oluşturulmuş dokümanlara göz atın.