الرسم على اللوحة في Emscripten

تعرّف على كيفية عرض رسومات ثنائية الأبعاد على الويب من WebAssembly باستخدام Emscripten.

تحتوي أنظمة التشغيل المختلفة على واجهات برمجة تطبيقات مختلفة لرسم الرسومات. تصبح الاختلافات أكثر إرباكًا عند كتابة رمز برمجي من عدّة منصات أو نقل الرسومات من نظام إلى آخر، بما في ذلك عند نقل الرمز الأصلي إلى WebAssembly.

وستتعلم في هذا المنشور طريقتين لرسم رسومات ثنائية الأبعاد إلى عنصر لوحة الرسم على الويب من كود C أو C++ الذي تم تجميعه باستخدام Emscripten.

لوحة الرسم عبر Embind

إذا كنت تبدأ مشروعًا جديدًا بدلاً من محاولة نقل مشروع حالي، قد يكون من الأسهل استخدام HTML Canvas API من خلال نظام ربط Emscripten Embind. يسمح لك Embind بالعمل مباشرةً باستخدام قيم JavaScript عشوائية.

لفهم كيفية استخدام Embind، ألقِ أولاً نظرة على المثال التالي من MDN الذي يعثر على <canvas>. ويرسم بعض الأشكال عليه

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

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

إليك كيفية تحويله صوتيًا إلى C++ باستخدام 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);
}

عند ربط هذا الرمز، احرص على تمرير --bind لتفعيل Embind:

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

بعد ذلك، يمكنك عرض الأصول المجمّعة باستخدام خادم ثابت وتحميل المثال في متصفّح:

صفحة HTML تم إنشاؤها باستخدام النص البرمجي تعرض مستطيلاً أخضر على لوحة رسم سوداء.

اختيار عنصر لوحة الرسم

عند استخدام هيكل HTML الذي أنشأه Emscripten مع أمر Shell السابق، يتم تضمين لوحة الرسم وإعدادها لك. حيث يسهّل إنشاء عروض توضيحية وأمثلة بسيطة، ولكن في التطبيقات الأكبر حجمًا، قد تحتاج إلى تضمين JavaScript وWebAssembly الذي تم إنشاؤه من خلال Emscripten في صفحة HTML من تصميمك الخاص.

يتوقع رمز JavaScript الذي تم إنشاؤه العثور على عنصر لوحة الرسم المخزّن في السمة Module.canvas. وكما هو الحال مع خصائص الوحدة الأخرى، يمكن ضبط إعداداتها أثناء الإعداد.

إذا كنت تستخدم الوضع ES6 (يتم ضبط الإخراج على مسار باستخدام الإضافة .mjs أو استخدام الإعداد -s EXPORT_ES6)، يمكنك ضبط لوحة الرسم على النحو التالي:

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

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

إذا كنت تستخدم إخراج نص برمجي عادي، عليك تعريف الكائن Module قبل تحميل ملف JavaScript الذي أنشأه Emscripten:

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

OpenGL وSDL2

OpenGL هي واجهة برمجة تطبيقات شائعة من عدّة منصات لرسومات الكمبيوتر. وعند استخدامها في Emscripten، ستتولى تحويل المجموعة الفرعية المدعومة من عمليات OpenGL إلى WebGL. إذا كان تطبيقك يعتمد على ميزات متوافقة مع OpenGL ES 2.0 أو 3.0، ولكن ليس في WebGL، سيكون بإمكان Emscripten محاكاتها أيضًا، ولكن عليك الموافقة من خلال الإعدادات المقابلة.

يمكنك استخدام OpenGL إما مباشرةً أو عبر مكتبات رسومات ثلاثية الأبعاد ذات مستوى أعلى. وقد تم نقل بعضها إلى الويب من خلال Emscripten. سأركّز في هذه المشاركة على الرسومات الثنائية الأبعاد، وبما أنّ SDL2 هي المكتبة المفضّلة حاليًا، فهي تم اختبارها جيدًا وتتيح استخدام واجهة Emscripten الخلفية رسميًا قبل إطلاقها.

رسم مستطيل

"معلومات عن SDL" على الموقع الإلكتروني الرسمي:

طبقة DirectMedia بسيطة هي مكتبة تطوير متعددة الأنظمة الأساسية تم تصميمها لتوفير مستوى منخفض للوصول إلى أجهزة الصوت ولوحة المفاتيح والماوس وذراع التحكم وأجهزة الرسومات عبر OpenGL وDirect3D.

تم نقل كل هذه الميزات، مثل التحكّم في الصوت ولوحة المفاتيح والماوس والرسومات، وتعمل باستخدام برنامج Emscripten على الويب أيضًا كي تتمكّن من نقل ألعاب كاملة تم إنشاؤها باستخدام SDL2 بدون عناء. إذا كنت بصدد نقل مشروع حالي، يُرجى الاطّلاع على قسم "الدمج مع نظام إصدار" في مستندات Emscripten.

للتبسيط، سأركز في هذه المشاركة على حالة ملف واحد وأترجم مثال المستطيل السابق إلى 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
}

عند الربط بحساب Emscripten، عليك استخدام -s USE_SDL=2. سيؤدي هذا إلى توجيه Emscripten إلى جلب مكتبة SDL2، التي تم تجميعها مسبقًا مع WebAssembly، وربطها بتطبيقك الرئيسي.

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

عند تحميل المثال في المتصفح، سيظهر لك المستطيل الأخضر المألوف:

صفحة HTML تمّ إنشاؤها باستخدام النص البرمجي تعرض مستطيلاً أخضر على لوحة مربّعة سوداء

بالرغم من ذلك، فإن هذا الرمز به مشكلتان. أولاً، يفتقر إلى التنظيف المناسب للموارد المخصصة. ثانيًا، لا يتم إغلاق الصفحات تلقائيًا على الويب عند انتهاء تنفيذ التطبيق، لذلك يتم الاحتفاظ بالصورة الموجودة على لوحة الرسم. ومع ذلك، عند إعادة تجميع الرمز البرمجي نفسه محليًا باستخدام

clang example.cpp -o example -lSDL2

وتنفيذه، لن تومض النافذة التي تم إنشاؤها إلا لفترة وجيزة وسيتم إغلاقها فورًا عند الخروج، وبالتالي لن يتمكن المستخدم من رؤية الصورة.

دمج حلقة أحداث

وهناك مثال أكثر اكتمالاً وتعبيرًا يحتاج إلى الانتظار في حلقة الحدث حتى يختار المستخدم إنهاء التطبيق:

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

بعد رسم الصورة على نافذة، ينتظر التطبيق الآن في حلقة واحدة، حيث يمكنه معالجة لوحة المفاتيح والماوس وأحداث المستخدم الأخرى. عندما يغلق المستخدِم النافذة، سيؤدّي إلى تشغيل حدث SDL_QUIT الذي يتم اعتراضه للخروج من التكرار الحلقي. بعد الخروج من التكرار الحلقي، سيقوم التطبيق بالتنظيف ثم يخرج من نفسه.

تعمل الآن تجميع هذا المثال على Linux كما هو متوقع وتظهر نافذة بحجم 300 × 300 بمستطيل أخضر:

نافذة مربّعة في نظام التشغيل Linux بخلفية سوداء ومستطيل أخضر

ومع ذلك، لم يعُد المثال متاحًا على الويب. تتوقّف الصفحة التي يتم إنشاؤها من خلال Emscripten مباشرةً أثناء التحميل ولا تعرض الصورة المعروضة مطلقًا:

صفحة HTML منشأة باستخدام النص متراكبة مع &quot;صفحة غير مستجيبة&quot; خطأ في مربّع حوار يقترح إما الانتظار حتى تصبح الصفحة مسؤولة أو الخروج منها

ما السبب؟ سأقتبس الإجابة من المقالة "استخدام واجهات برمجة تطبيقات الويب غير المتزامنة من WebAssembly":

الإجابة المختصرة هي أنّ المتصفّح يشغّل جميع الرموز البرمجية في شكل تكرار لا نهائي، من خلال نقلها من قائمة الانتظار واحدة تلو الأخرى. عند تشغيل حدث ما، يضع المتصفح المعالج المقابل في قائمة الانتظار، وعند التكرار الحلقي التالي، يتم إخراجه من قائمة الانتظار وتنفيذه. تسمح هذه الآلية بمحاكاة التزامن وتنفيذ العديد من العمليات المتوازية مع استخدام سلسلة محادثات واحدة فقط.

من المهم أن تتذكّر بشأن هذه الآلية أنّه أثناء تنفيذ رمز JavaScript المخصّص (أو WebAssembly)، يتم حظر حلقة الحدث [...].

ينفِّذ المثال السابق حلقة حدث لا نهائي، بينما يعمل الرمز نفسه داخل حلقة أحداث لا نهائية أخرى، يوفّرها المتصفّح ضمنيًا. ولا تتخلى الحلقة الداخلية مطلقًا عن التحكم في الجزء الخارجي، وبالتالي لا يحظى المتصفح بفرصة لمعالجة الأحداث الخارجية أو إضافة عناصر إلى الصفحة.

هناك طريقتان لحلّ هذه المشكلة.

إزالة حظر تكرار الأحداث باستخدام Asyncify

أولاً، يمكنك استخدام Asyncify كما هو موضّح في المقالة المرتبطة. وهي ميزة Emscripten تسمح "بالإيقاف المؤقت" برنامج C أو C++ ، ومنح التحكم مرة أخرى في حلقة الحدث، وتنشيط البرنامج عند انتهاء بعض العمليات غير المتزامنة.

ويمكن أن تشير هذه العملية غير المتزامنة إلى "النوم لأقل وقت ممكن"، ويتم التعبير عنها عبر واجهة برمجة تطبيقات emscripten_sleep(0). ومن خلال تضمينه في منتصف التكرار الحلقي، يمكنني ضمان إعادة عنصر التحكم إلى حلقة أحداث المتصفح في كل تكرار، وأن تظل الصفحة سريعة الاستجابة ويمكنها التعامل مع أي أحداث:

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

يجب الآن تجميع هذا الرمز مع تفعيل Asyncify:

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

ويعمل التطبيق على النحو المتوقع على الويب مرة أخرى:

صفحة HTML تمّ إنشاؤها باستخدام النص البرمجي تعرض مستطيلاً أخضر على لوحة مربّعة سوداء

ومع ذلك، قد يشتمل Asyncify على أعباء أساسية لحجم الرمز البرمجي. إذا كانت تُستخدم فقط في حلقة أحداث من المستوى الأعلى في التطبيق، يمكن أن يكون استخدام الدالة emscripten_set_main_loop خيارًا أفضل.

إزالة حظر تكرار الأحداث من خلال "الحلقة الرئيسية" واجهات برمجة التطبيقات

ولا يتطلّب emscripten_set_main_loop أي عمليات تحويل في برنامج التجميع لإرجاع حزمة الاستدعاءات وترجيعها، وبهذه الطريقة، يتم تجنُّب أعباء حجم الرمز البرمجي. ومع ذلك، فإنه في المقابل، يتطلب ذلك إجراء المزيد من التعديلات اليدوية على الرمز.

أولاً، يجب استخراج نص حلقة الحدث إلى دالة منفصلة. بعد ذلك، يجب استدعاء الدالة emscripten_set_main_loop باستخدام هذه الدالة كاستدعاء في الوسيطة الأولى، وFPS في الوسيطة الثانية (0 لفاصل إعادة التحميل الأصلي)، وقيمة منطقية تشير إلى ما إذا كان ستتم محاكاة حلقة لانهائية (true) في الوسيطة الثالثة:

emscripten_set_main_loop(callback, 0, true);

لن يتمكّن الاستدعاء الذي تم إنشاؤه حديثًا من الوصول إلى متغيّرات تسلسل استدعاء الدوال البرمجية في الدالة main، لذا يجب إما استخراج متغيّرات مثل window وrenderer في بنية مخصصة لأجزاء من الذاكرة وتمرير مؤشرها عبر صيغة emscripten_set_main_loop_arg من واجهة برمجة التطبيقات، أو استخراجها إلى متغيّرات static عمومية (تم اختيار المتغيرات الأخرى لتبسيط الأمر). النتيجة أصعب قليلاً متابعة النتيجة، لكنها ترسم نفس المستطيل مثل المثال الأخير:

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

ونظرًا لأن جميع تغييرات تدفق التحكم تتم يدويًا وتنعكس في التعليمة البرمجية المصدر، فيمكن تجميعه بدون ميزة Asyncify مرة أخرى:

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

قد يبدو هذا المثال عديم الجدوى، لأنه لا يعمل بشكل مختلف عن الإصدار الأول، حيث تم رسم المستطيل على لوحة الرسم بنجاح على الرغم من كون الرمز أبسط بكثير، ويتم تجاهل حدث SDL_QUIT، وهو الحدث الوحيد الذي تمت معالجته في دالة handle_events، على الويب على أي حال.

مع ذلك، فإنّ الدمج المناسب لحلقة الأحداث، سواء من خلال Asyncify أو عبر emscripten_set_main_loop، يؤتي ثماره إذا قررت إضافة أي نوع من الصور المتحركة أو التفاعل.

التعامل مع تفاعلات المستخدمين

على سبيل المثال، من خلال إجراء بعض التغييرات على المثال الأخير، يمكنك جعل المستطيل يتحرك استجابة لأحداث لوحة المفاتيح:

#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

تعمل SDL2 على استخلاص الاختلافات عبر الأنظمة الأساسية وأنواع مختلفة من أجهزة الوسائط في واجهة برمجة تطبيقات واحدة، ولكنها لا تزال مكتبة منخفضة المستوى. وعلى وجه الخصوص بالنسبة للرسومات، في حين أنه يوفر واجهات برمجة تطبيقات لرسم النقاط والخطوط والمستطيلات، يتم ترك تنفيذ أي أشكال وتحويلات أكثر تعقيدًا للمستخدم.

SDL2_gfx هي مكتبة منفصلة تملأ هذه الفجوة. على سبيل المثال، يمكن استخدامه لاستبدال مستطيل في المثال أعلاه بدائرة:

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

الآن، يجب أيضًا ربط مكتبة SDL2_gfx بالتطبيق. يتم ذلك بشكل مشابه لـ 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

وإليك النتائج التي يتم تشغيلها على نظام التشغيل Linux:

نافذة مربّعة في نظام التشغيل Linux بخلفية سوداء ودائرة خضراء

وعلى الويب:

صفحة HTML تمّ إنشاؤها باستخدام النص البرمجي تعرض دائرة خضراء على لوحة مربّعة سوداء

لمزيد من العناصر الأساسية للرسومات، يمكنك الاطّلاع على المستندات التي تم إنشاؤها تلقائيًا.