تعرَّف على كيفية عرض الرسومات ثنائية الأبعاد على الويب من 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 مع الأمر السابق لملف شل، يتم تضمين اللوحة وإعدادها نيابةً عنك. يسهّل هذا الإجراء إنشاء نماذج توضيحية وأمثلة بسيطة، ولكن في التطبيقات الأكبر حجمًا، عليك تضمين 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
عند تحميل المثال في المتصفّح، سيظهر لك المستطيل الأخضر المألوف:
ومع ذلك، هناك بعض المشاكل في هذا الرمز. أولاً، لا يتضمّن هذا الإجراء عملية تنظيف مناسبة للموارد المخصّصة. ثانيًا، على الويب، لا يتم إغلاق الصفحات تلقائيًا عند انتهاء تنفيذ أحد التطبيقات، وبالتالي يتم الاحتفاظ بالصورة على اللوحة. ومع ذلك، عند إعادة تجميع الرمز نفسه بشكل أصلي باستخدام
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 x 300 مع مستطيل أخضر:
ومع ذلك، لم يعُد هذا المثال صالحًا على الويب. تتوقّف الصفحة التي تم إنشاؤها باستخدام Emscripten على الفور أثناء التحميل ولا تعرِض الصورة المعروضة مطلقًا:
ما السبب؟ سأقتبس الإجابة من المقالة "استخدام واجهات برمجة التطبيقات غير المتزامنة للويب من 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
يعمل التطبيق على الويب على النحو المتوقّع مرة أخرى:
ومع ذلك، يمكن أن تتسبب 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();
}
بما أنّ جميع تغييرات تدفّق التحكّم يدوية وتظهر في رمز المصدر، يمكن تجميعها بدون ميزة 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:
على الويب:
لمزيد من العناصر الأساسية للرسومات، اطّلِع على المستندات المنشأة تلقائيًا.