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

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

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

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

Canvas عبر Embind

إذا كنت بصدد بدء مشروع جديد بدلاً من محاولة نقل مشروع حالي، قد يكون من الأسهل استخدام Canvas API في 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 من إنشاء Emscripten تعرض مستطيلاً أخضر على لوحة سوداء

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

عند استخدام هيكل 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 من إنشاء Emscripten تعرِض مستطيلاً أخضر على لوحة مربعة سوداء

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

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 من إنشاء Emscripten معروضة فوق مربّع حوار خطأ &quot;الصفحة لا تستجيب&quot; يقترح الانتظار إلى أن تستجيب الصفحة أو الخروج منها

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

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

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

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

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

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

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

ويمكن أن تشير هذه العملية غير المتزامنة إلى "النوم لأقل وقت ممكن"، ويتم التعبير عنها عبر واجهة برمجة تطبيقات 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 من إنشاء Emscripten تعرِض مستطيلاً أخضر على لوحة مربعة سوداء

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

إزالة حظر حلقة الأحداث باستخدام واجهات برمجة التطبيقات &quot;للحلقة الرئيسية&quot;

لا تتطلّب 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();
}

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

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