Emscripten'da kanvasa çizim

Emscripten ile WebAssembly'den web'de 2D grafikler oluşturmayı öğrenin.

Ingvar Stepanyan
Ingvar Stepanyan

Farklı işletim sistemlerinin grafik çizimi için farklı API'ları vardır. Platformlar arası kod yazarken veya yerel kodu WebAssembly'ye taşıma işlemi de dahil olmak üzere grafikleri bir sistemden diğerine taşırken farklılıklar daha da karmaşık hale gelir.

Bu gönderide, C veya C++ kodundan Emscripten ile derlenmiş olan C veya C++ kodundan web'deki tuval öğesine 2D grafikler çizmek için kullanılan iki yöntemi öğreneceksiniz.

Embind üzerinden tuval

Mevcut bir projeyi taşımaya çalışmak yerine yeni bir proje başlatıyorsanız en kolay yöntem, Emscripten'in Embind bağlama sistemi üzerinden HTML Canvas API'yi kullanmak olabilir. Embind, rastgele JavaScript değerleri üzerinde doğrudan çalışmanıza olanak tanır.

Embind'in nasıl kullanılacağını anlamak için önce <canvas> içeren aşağıdaki MDN örneğine göz atın. öğesi üzerinde bazı şekiller çiziyor.

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

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

Embind ile C++ diline nasıl dönüştürülebileceğ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, derlenen öğeleri statik bir sunucuyla sunabilir ve örneği bir tarayıcıya yükleyebilirsiniz:

Siyah bir tuval üzerinde yeşil bir dikdörtgen gösteren, Emscripten tarafından oluşturulmuş HTML sayfası.

Tuval öğesini seçme

Önceki kabuk komutuyla Emscripten tarafından oluşturulan HTML kabuğu kullanıldığında tuval dahil edilir ve sizin için ayarlanır. Basit demolar ve örnekler oluşturmayı kolaylaştırır, ancak daha büyük uygulamalarda Emscripten tarafından oluşturulan JavaScript ve WebAssembly'yi kendi tasarımınızın bir HTML sayfasına eklemek istersiniz.

Oluşturulan JavaScript kodunun, Module.canvas özelliğinde depolanan tuval öğesini bulması beklenir. Diğer Modül özellikleri gibi bu da başlatma sırasında ayarlanabilir.

ES6 modunu kullanıyorsanız (çıktıyı .mjs uzantısına sahip bir yola ayarlama veya -s EXPORT_ES6 ayarını) kullanıyorsanız kanvası şu şekilde geçirebilirsiniz:

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 bildirmeniz 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, OpenGL işlemlerinin desteklenen alt kümesini WebGL'ye dönüştürme işlemini gerçekleştirir. Uygulamanız OpenGL ES 2.0 veya 3.0'da desteklenen özelliklere dayanıyor ancak WebGL'de desteklenmiyorsa Emscripten, bunların emülasyonunu da gerçekleştirebilir, ancak ilgili ayarları kullanarak etkinleştirmeniz gerekir.

OpenGL'yi doğrudan veya daha üst düzey 2D ve 3D grafik kitaplıkları aracılığıyla kullanabilirsiniz. Bunlardan birkaçı Emscripten ile web'e taşınmıştır. Bu gönderide 2D grafiklere odaklanıyorum. Bu açıdan iyi test edilmiş olması ve Emscripten arka ucunu resmi olarak yukarı yönde desteklediği için SDL2 şu anda tercih edilen kitaplıktır.

Dikdörtgen çizme

"SDL hakkında" resmi web sitesindeki bölümde şu bilgiler yer alır:

Basit DirectMedia Katmanı, OpenGL ve Direct3D aracılığıyla ses, klavye, fare, kontrol çubuğu ve grafik donanımına alt düzey erişim sağlamak için tasarlanmış platformlar arası bir geliştirme kitaplığıdır.

Ses, klavye, fare ve grafikleri kontrol eden tüm bu özellikler, web'de de Emscripten'le çalışabiliyor. Böylece, SDL2 ile geliştirilen oyunların tamamını sorunsuz bir şekilde taşıyabilirsiniz. Mevcut bir projeyi taşıyorsanız Emscripten belgelerinin "Derleme sistemiyle entegre etme" bölümüne göz atın.

Basitlik sağlaması için bu gönderide tek dosyalık bir işleme 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 işlem, Emscripten'e WebAssembly'de önceden 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, bilinen yeşil dikdörtgeni görürsünüz:

Siyah kare tuval üzerinde yeşil bir dikdörtgen gösteren, Emscripten tarafından oluşturulmuş HTML sayfası.

Ancak bu kodda birkaç sorun vardır. İlk olarak, tahsis edilen kaynaklar düzgün bir şekilde temizlenmiyor. İkincisi, web'de bir uygulama çalıştırıldığında sayfalar otomatik olarak kapanmaz. Böylece tuvaldeki resim korunur. Ancak aynı kod

clang example.cpp -o example -lSDL2

çalıştırıldığında, oluşturulan pencere yalnızca kısa bir süre yanıp söner ve çıkış anında hemen kapanacaktır. Bu nedenle, kullanıcının resmi görme şansı olmayacaktır.

Etkinlik döngüsünü entegre etme

Daha eksiksiz ve deyimsel bir örnek, kullanıcı uygulamadan çıkmayı seçene kadar bir etkinlik döngüsünde beklemek gibi görünür:

#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 artık klavye, fare ve diğer kullanıcı etkinliklerini işleyebildiği bir döngüde beklemeye başlar. Kullanıcı pencereyi kapattığında döngüden çıkmak için müdahale edilecek bir SDL_QUIT etkinliği tetiklenir. Döngüden çıkıldıktan sonra uygulama temizleme işlemini gerçekleştirir ve kendisinden çıkar.

Bu örnek Linux'ta derlendiğinde beklendiği gibi çalışır ve yeşil bir dikdörtgen ile 300x300 boyutunda bir pencere gösterir:

Siyah arka plan ve yeşil dikdörtgen ile kaplı kare bir Linux penceresi.

Ancak bu örnek artık web'de çalışmamaktadır. 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şturulmuş HTML sayfası üzerinde &#39;Sayfa Yanıt Vermiyor&#39; yazısı sayfanın sorumlu duruma geçmesini beklemeyi veya sayfadan çıkmayı öneren hata iletişim kutusu

Ne oldu? "WebAssembly'den eşzamansız web API'lerini kullanma" makalesindeki yanıtı alıntılayacağım:

Kısacası, tarayıcı tüm kod parçalarını sıradan tek tek alarak sonsuz döngü içinde çalıştırır. Bir etkinlik tetiklendiğinde, tarayıcı ilgili işleyiciyi sıraya alır ve bir sonraki döngü yinelemesinde sıradan çıkarılarak yürütülür. Bu mekanizma, eşzamanlılığın simüle edilmesine ve yalnızca tek bir iş parçacığı kullanılarak çok sayıda paralel işlem yürütülmesine olanak tanır.

Bu mekanizma hakkında unutulmaması gereken önemli nokta, özel JavaScript (veya WebAssembly) kodunuz çalışırken etkinlik döngüsünün engellendiğidir [...]

Yukarıdaki örnek, bir sonsuz etkinlik döngüsü yürütürken kodun kendisi de 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ıştakine bırakmaz. Dolayısıyla, tarayıcının harici etkinlikleri işleme veya sayfaya bir şeyler çizme fırsatı olmaz.

Bu sorunu çözmenin iki yolu vardır.

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

İlk olarak, bağlantılı makalede açıklandığı gibi Asyncify'ı kullanabilirsiniz. Bu, "duraklatmaya" olanak tanıyan bir Emscripten özelliğidir C veya C++ programına geri dönmek, etkinlik döngüsünü tekrar kontrol etmek ve bazı eşzamansız işlemler tamamlandığında programı uyandırmak için kullanılır.

Bu tür eşzamansız işlemler, emscripten_sleep(0) API ile ifade edildiği "mümkün olan minimum süre boyunca uyku" bile olabilir. Bunu döngünün ortasına yerleştirerek kontrolün her iterasyonda tarayıcının etkinlik döngüsüne döndürüldüğünden ve sayfanın duyarlı kalmasını ve her türlü etkinliği işleyebildiğinden emin olabilirim:

#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 artık Asyncify etkinken derlenmesi gerekiyor:

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

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

Siyah kare tuval üzerinde yeşil bir dikdörtgen gösteren, Emscripten tarafından oluşturulmuş HTML sayfası.

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

"Ana döngü" ile etkinlik döngüsünün engellemesi kaldırılıyor API'ler

emscripten_set_main_loop, çağrı yığınını açmak ve geri sarmak için derleyici dönüşümü gerektirmez ve böylece kod boyutu ek yükünü önler. Ancak bunun karşılığında kodda çok daha fazla manuel değişiklik yapmak gerekir.

Öncelikle, etkinlik döngüsünün gövdesinin ayrı bir işleve çıkarılması gerekir. Ardından, ilk bağımsız değişkende geri çağırma işleviyle emscripten_set_main_loop, 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ü (true) simülasyonunun yapılıp yapılmayacağını gösteren bir boole olarak bu işlevle çağrılmalıdır:

emscripten_set_main_loop(callback, 0, true);

Yeni oluşturulan geri çağırmanın main işlevindeki yığın değişkenlerine erişimi olmayacağından, window ve renderer gibi değişkenlerin yığınla ayrılmış bir struct'a çıkarılması ve işaretçisinin API'nin emscripten_set_main_loop_arg varyantı üzerinden geçirilmesi ya da genel static değişkenlerine (basitlik için ikinciyi tercih ettim) çıkarılması gerekir. Sonucu takip etmek biraz daha zordur ancak son örnektekiyle 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ıldığından ve kaynak koduna yansıtıldığından bu değişiklikler Asyncify özelliği olmadan tekrar derlenebilir:

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

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

Bununla birlikte, herhangi bir animasyon veya etkileşim eklemek isterseniz Asyncify veya emscripten_set_main_loop aracılığıyla uygun etkinlik döngüsü entegrasyonunun karşılığını alırsınız.

Kullanıcı etkileşimlerini yönetme

Örneğin, son örnekte birkaç değişiklik yaparak dikdörtgeni klavye etkinliklerine göre hareket ettirebilirsiniz:

#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 başka şekiller çizme

SDL2, tek bir API'de platformlar arası farklılıkları ve çeşitli medya cihazı türlerini soyutlasa da hâlâ oldukça düşük seviyede bir kitaplıktır. Özellikle grafikler için çizim noktaları, çizgiler ve dikdörtgenler için API'ler sağlar, ancak 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 örnekte bir 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'ta çalışan sonuçlar şu şekildedir:

Siyah arka plan ve yeşil daire bulunan kare bir Linux penceresi.

Web'de:

Siyah kare bir tuval üzerinde yeşil bir daire gösteren, Emscripten tarafından oluşturulmuş HTML sayfası.

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