Emscripten'da kanvasa çizim

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

İngvar Stepanyan
Ingvar Stepanyan

Farklı işletim sistemleri, grafik çizmek için farklı API'lara sahiptir. Platformlar arası kod yazarken veya grafikleri bir sistemden diğerine taşırken yerel kodun WebAssembly'ye taşınması gibi durumlarda farklılıklar daha da kafa karıştırıcı hale gelir.

Bu yayında, Emscripten ile derlenen C veya C++ kodundan web'deki tuval öğesine 2D grafikler çizmek için kullanılan birkaç yöntemi öğreneceksiniz.

Embind aracılığıyla tuval

Mevcut bir projeyi taşımaya çalışmak yerine yeni bir projeye başlıyorsanız Emscripten'in Embind bağlama sistemi üzerinden HTML Canvas API'yi kullanmak en kolay yöntem 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> öğesi bulan ve üzerinde bazı şekiller çizen aşağıdaki MDN örneğine göz atın.

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

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

Embind kullanılarak C++'a şu şekilde harf çevirisi yapılabilir:

#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 değerini ilettiğinizden emin olun:

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

Daha sonra, derlenen öğeleri statik bir sunucuyla sunabilir ve örneği bir tarayıcıya yükleyebilirsiniz:

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

Tuval öğesini seçme

Emscripten tarafından oluşturulan HTML kabuğunu önceki kabuk komutuyla kullandığınızda tuval eklenir 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 öğelerini kendi tasarımınıza sahip bir HTML sayfasına eklemek istersiniz.

Oluşturulan JavaScript kodu, Module.canvas özelliğinde depolanan tuval öğesini bulmayı bekler. Diğer Modül özelliklerinde olduğu gibi, başlatma sırasında ayarlanabilir.

ES6 modunu kullanıyorsanız (çıkışı .mjs uzantısına sahip bir yola ayarlıyor 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 kullanılan popüler bir platformlar arası API'dir. Emscripten'de kullanıldığında, desteklenen OpenGL işlemleri alt kümesini WebGL'ye dönüştürmeyi sağlar. Uygulamanız OpenGL ES 2.0 veya 3.0'da desteklenen ancak WebGL'de desteklenmeyen özelliklere dayanıyorsa Emscripten, bunların öyküsünü de halledebilir, ancak sizin ilgili ayarları kullanarak bu özelliği etkinleştirmeniz gerekir.

OpenGL’yi doğrudan veya üst seviye 2D ve 3D grafik kitaplıkları aracılığıyla kullanabilirsiniz. Bunlardan birkaçı Emscripten ile web'e taşındı. Bu gönderide 2D grafiğe odaklanıyorum. SDL2 iyi test edildiğinden ve resmi olarak yukarı yönlü Emscripten arka ucunu desteklediği için şu anda tercih edilen kitaplıktır.

Dikdörtgen çizme

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

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

Ses, klavye, fare ve grafikleri kontrol etme gibi tüm bu özellikler taşınmış ve web'de Emscripten ile de kullanılabilmektedir. Böylece, SDL2 ile geliştirilen tüm oyunları kolayca taşıyabilirsiniz. Mevcut bir projeyi taşıyorsanız Emscripten dokümanlarının "Derleme sistemiyle entegre etme" bölümüne göz atın.

Basitlik sağlaması için bu gönderide tek dosyalık bir dosya üzerinde duracağım ve önceki dikdörtgen örneği 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'a WebAssembly'de önceden derlenmiş SDL2 kitaplığını getirmesini ve ana uygulamanıza bağlamasını ister.

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 zemin üzerinde yeşil bir dikdörtgenin gösterildiği, Emscripten tarafından oluşturulmuş HTML sayfası.

Ancak bu kodda bazı sorunlar var. Öncelikle, tahsis edilen kaynaklar uygun şekilde temizlenmez. İkinci olarak, web'de, bir uygulamanın yürütmesi bittiğinde sayfalar otomatik olarak kapatılmaz. Bu nedenle, zemindeki resim korunur. Ancak aynı kod

clang example.cpp -o example -lSDL2

oluşturulan pencere yalnızca kısa bir süre yanıp söner ve çıkıştan hemen sonra kapanır. Böylece kullanıcının resmi görme fırsatı olmaz.

Olay 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 beklenmesi gerektiğini belirtir:

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

Görüntü bir pencereye çizildikten sonra, uygulama şimdi bir döngü halinde bekler. Burada klavye, fare ve diğer kullanıcı etkinliklerini işleyebilir. Kullanıcı pencereyi kapattığında, döngüden çıkmak için müdahale edilecek bir SDL_QUIT etkinliği tetikler. Döngüden çıkıldıktan sonra uygulama temizleme işlemini gerçekleştirir ve sonra kendisinden çıkar.

Bu örnek Linux'ta derlendiğinde beklendiği gibi çalışır ve yeşil bir dikdörtgenle 300 x 300 boyutunda bir pencere görüntülenir:

Siyah arka plan ve yeşil dikdörtgen bulunan kare Linux penceresi.

Ancak ö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:

Sayfanın sorumlu hale gelmesi için beklemeyi veya sayfadan çıkmayı öneren &quot;Sayfa Yanıt Vermiyor&quot; hata iletişimiyle yer paylaşımlı, Emscripten tarafından oluşturulmuş HTML sayfası

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

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

Bu mekanizma ile ilgili unutulmaması gereken önemli nokta, özel JavaScript (veya WebAssembly) kodunuz çalışırken etkinlik döngüsünün engellenmesidir [...]

Yukarıdaki örnek bir sonsuz etkinlik döngüsü yürütürken kodun kendisi tarayıcı tarafından dolaylı olarak sağlanan başka bir sonsuz etkinlik döngüsünün içinde çalışır. İç döngü, kontrolü asla dıştakine bırakmaz. Bu nedenle, 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

Öncelikle, bağlantılı makalede açıklandığı gibi Asyncify özelliğini kullanabilirsiniz. C veya C++ programının "duraklatılmasına", olay döngüsünün kontrolünün yeniden verilmesine ve eşzamansız işlem tamamlandığında programın uyandırılmasına olanak tanıyan bir Emscripten özelliğidir.

Bu tür eşzamansız işlemler, emscripten_sleep(0) API'si aracılığıyla "mümkün olan en kısa süre uykuda" bile olabilir. Kodu döngünün ortasına yerleştirerek kontrolün her iterasyonda tarayıcının etkinlik 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 artık Asyncify etkinken derlenmesi gerekir:

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

Ayrıca, uygulama yine web'de beklendiği gibi çalışır:

Siyah kare zemin üzerinde yeşil bir dikdörtgenin gösterildiği, Emscripten tarafından oluşturulmuş HTML sayfası.

Ancak Asyncify'ın önemsiz olmayan kod boyutu ek yükü 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ı geri sarmak ve geri sarmak için herhangi bir derleyici dönüşümü gerektirmediğinden kod boyutu ek yükünü önler. Bununla birlikte, kodda çok daha fazla manuel değişiklik yapılması gerekir.

Öncelikle, etkinlik döngüsünün gövdesinin ayrı bir işlevde ayıklanması gerekir. Ardından emscripten_set_main_loop, ilk bağımsız değişkende geri çağırma olarak bu işlevle, ikinci bağımsız değişkende bir 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ı belirten bir boole ile çağrılmalıdır:

emscripten_set_main_loop(callback, 0, true);

Yeni oluşturulan geri çağırma, main işlevindeki yığın değişkenlerine erişemez. Bu nedenle, 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 veya genel static değişkenlerine çıkarılması gerekir (basit olması için ikinci örneği tercih ettim). Sonucun izlenmesi biraz daha zor olur, ancak son örnektekiyle aynı dikdörtgen çizilir:

#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 denetim akışı değişiklikleri manuel olduğundan ve kaynak koda yansıtıldığından, 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 tuval üzerinde 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 yoksayıldığı için işe yaramaz gibi görünebilir.

Ancak Asyncify veya emscripten_set_main_loop aracılığıyla yapılan uygun etkinlik döngüsü entegrasyonu, herhangi bir animasyon veya etkileşim türü eklemeye karar verirseniz karşılığını verir.

Kullanıcı etkileşimlerini yönetme

Örneğin, son örnekte birkaç değişiklik yaparak klavye etkinliklerine tepki olarak dikdörtgenin 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 başka şekiller çizme

SDL2, tek bir API'de platformlar arası farklılıkları ve çeşitli medya cihazlarını soyutlasa da hâlâ oldukça alt düzey bir kitaplıktır. Özellikle grafikler için, nokta, çizgi ve dikdörtgen çizme API'leri sağlasa da 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 dikdörtgeni bir 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. SDL2'ye benzer şekilde gerçekleştirilir:

# 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ıştırılan sonuçlar ise şunlardır:

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

Web'de ise:

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

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