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

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

إنغفار ستيبانيان
إنغفار ستيبانيان

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

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

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

إذا كنت تبدأ مشروعًا جديدًا بدلاً من محاولة نقل مشروع حالي، قد يكون من الأسهل استخدام HTML واجهة برمجة تطبيقات لوحة الرسم عبر نظام الربط Embind في Emscripten. يسمح لك 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" على الموقع الإلكتروني الرسمي ما يلي:

Simple DirectMedia Layer هي مكتبة تطوير على عدة أنظمة أساسية تم تصميمها لتوفير إمكانية وصول منخفض المستوى إلى أجهزة الصوت ولوحة المفاتيح والماوس وذراع التحكّم والرسومات عبر 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; يقترح إما انتظار الصفحة حتى تصبح مسؤولة أو الخروج منها

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

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

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

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

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

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

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

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

#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 مع هذه الدالة كاستدعاء في الوسيطة الأولى، وعدد لقطات في الثانية في الوسيطة الثانية (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();
}

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

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

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

في المقابل، إنّ الدمج الصحيح لتكرار الأحداث، سواء من خلال عدم المزامنة أو من خلال 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 تم إنشاؤها باستخدام نصوص توضيحية تعرض دائرة خضراء على لوحة مربّعة سوداء

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