طراحی روی بوم در Emscripten

نحوه رندر کردن گرافیک های دو بعدی در وب را از WebAssembly با Emscripten بیاموزید.

سیستم عامل های مختلف دارای API های متفاوتی برای ترسیم گرافیک هستند. این تفاوت ها هنگام نوشتن یک کد بین پلتفرمی یا انتقال گرافیک از یک سیستم به سیستم دیگر، از جمله هنگام انتقال کد بومی به WebAssembly، گیج کننده تر می شوند.

در این پست چند روش برای ترسیم گرافیک دوبعدی روی عنصر بوم روی وب از کدهای C یا C++ که با Emscripten کامپایل شده است را خواهید آموخت.

اگر به جای تلاش برای پورت کردن یک پروژه موجود، یک پروژه جدید را شروع می‌کنید، ممکن است ساده‌ترین کار استفاده از HTML Canvas API از طریق سیستم Binding Embind Emscripten باشد. Embind به شما امکان می دهد مستقیماً روی مقادیر دلخواه جاوا اسکریپت کار کنید.

برای درک نحوه استفاده از 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 با دستور پوسته قبلی، بوم گنجانده شده و برای شما تنظیم می شود. ساختن دموها و نمونه‌های ساده را آسان‌تر می‌کند، اما در برنامه‌های بزرگتر می‌خواهید جاوا اسکریپت و WebAssembly تولید شده توسط Emscripten را در یک صفحه HTML با طراحی خود قرار دهید.

کد جاوا اسکریپت تولید شده انتظار دارد عنصر بوم ذخیره شده در ویژگی Module.canvas را پیدا کند. مانند سایر ویژگی های ماژول ، می توان آن را در حین مقداردهی اولیه تنظیم کرد.

اگر از حالت ES6 استفاده می‌کنید (تنظیم خروجی به مسیری با پسوند .mjs یا با استفاده از تنظیم -s EXPORT_ES6 )، می‌توانید بوم را به این صورت منتقل کنید:

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

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

اگر از خروجی اسکریپت معمولی استفاده می کنید، باید قبل از بارگیری فایل جاوا اسکریپت تولید شده توسط Emscripten، شی Module را اعلام کنید:

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

OpenGL و SDL2

OpenGL یک API بین پلتفرمی محبوب برای گرافیک کامپیوتری است. هنگامی که در Emscripten استفاده می شود، از تبدیل زیر مجموعه پشتیبانی شده از عملیات OpenGL به WebGL مراقبت می کند. اگر برنامه شما به ویژگی‌هایی متکی است که در OpenGL ES 2.0 یا 3.0 پشتیبانی می‌شوند، اما نه در WebGL، Emscripten می‌تواند از شبیه‌سازی آن‌ها نیز مراقبت کند، اما شما باید از طریق تنظیمات مربوطه انتخاب کنید.

می توانید از OpenGL به صورت مستقیم یا از طریق کتابخانه های گرافیکی دو بعدی و سه بعدی سطح بالاتر استفاده کنید. تعدادی از آنها با Emscripten به وب منتقل شده اند. در این پست، من روی گرافیک دوبعدی تمرکز می‌کنم، و برای آن SDL2 در حال حاضر کتابخانه ترجیحی است، زیرا به خوبی آزمایش شده است و به طور رسمی از Emscripten در بالادست پشتیبانی می‌کند.

رسم مستطیل

بخش "درباره SDL" در وب سایت رسمی می گوید:

Simple DirectMedia Layer یک کتابخانه توسعه بین پلتفرمی است که برای دسترسی سطح پایین به صدا، صفحه کلید، ماوس، جوی استیک و سخت افزار گرافیکی از طریق OpenGL و Direct3D طراحی شده است.

تمام این ویژگی‌ها - کنترل صدا، صفحه کلید، ماوس و گرافیک - پورت شده‌اند و با Emscripten روی وب نیز کار می‌کنند تا بتوانید کل بازی‌های ساخته شده با SDL2 را بدون دردسر زیاد پورت کنید. اگر در حال انتقال یک پروژه موجود هستید، بخش «ادغام با یک سیستم ساخت» را در Emscripten docs بررسی کنید.

برای سادگی، در این پست بر روی یک مورد تک فایل تمرکز می کنم و مثال مستطیل قبلی را به 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 را راه اندازی می کند که برای خروج از حلقه متوقف می شود. پس از خروج از حلقه، برنامه پاکسازی را انجام می دهد و سپس خودش خارج می شود.

اکنون کامپایل این مثال در لینوکس همانطور که انتظار می رود کار می کند و یک پنجره 300 در 300 با یک مستطیل سبز نشان می دهد:

یک پنجره مربع لینوکس با پس زمینه سیاه و یک مستطیل سبز.

با این حال، مثال دیگر در وب کار نمی کند. صفحه ایجاد شده توسط Emscripten بلافاصله در حین بارگذاری ثابت می شود و هرگز تصویر رندر شده را نشان نمی دهد:

صفحه HTML ایجاد شده توسط Emscripten با یک گفتگوی خطای "Page Unresponsive" همپوشانی شده است که پیشنهاد می کند یا منتظر بمانید تا صفحه مسئول شود یا از صفحه خارج شوید.

چه اتفاقی افتاد؟ من پاسخ را از مقاله "استفاده از APIهای وب ناهمزمان از WebAssembly" نقل می کنم:

نسخه کوتاه این است که مرورگر تمام قطعات کد را به نوعی یک حلقه بی نهایت اجرا می کند و آنها را یک به یک از صف می گیرد. هنگامی که رویدادی راه اندازی می شود، مرورگر کنترل کننده مربوطه را در صف قرار می دهد و در تکرار حلقه بعدی از صف خارج شده و اجرا می شود. این مکانیسم امکان شبیه سازی همزمانی و اجرای بسیاری از عملیات موازی را در حالی که تنها از یک رشته استفاده می کند، می دهد.

نکته مهمی که در مورد این مکانیسم باید به خاطر بسپارید این است که، در حالی که کد جاوا اسکریپت (یا 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 ایجاد شده توسط Emscripten که یک مستطیل سبز را روی بوم مربع سیاه نشان می دهد.

با این حال، Asyncify می تواند سربار اندازه کد غیر ضروری داشته باشد. اگر فقط برای یک حلقه رویداد سطح بالا در برنامه استفاده می شود، گزینه بهتر می تواند استفاده از تابع emscripten_set_main_loop باشد.

رفع انسداد حلقه رویداد با APIهای "حلقه اصلی".

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 API ارسال شود، یا در آن استخراج شوند. متغیرهای 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 تفاوت‌های بین پلتفرمی و انواع مختلف دستگاه‌های رسانه‌ای را در یک API خلاصه می‌کند، اما هنوز یک کتابخانه بسیار سطح پایین است. به ویژه برای گرافیک، در حالی که API هایی برای ترسیم نقاط، خطوط و مستطیل ها ارائه می دهد، پیاده سازی هر شکل پیچیده تر و تبدیل به کاربر واگذار می شود.

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

و این هم نتایجی که روی لینوکس اجرا می شوند:

یک پنجره مربع لینوکس با پس زمینه سیاه و یک دایره سبز.

و در وب:

صفحه HTML ایجاد شده توسط Emscripten که یک دایره سبز رنگ را روی بوم مربع سیاه نشان می دهد.

برای اطلاعات اولیه گرافیکی بیشتر، اسناد تولید شده خودکار را بررسی کنید.