Vẽ lên canvas trong Emscripten

Tìm hiểu cách kết xuất đồ hoạ 2D trên web qua WebAssembly bằng Emscripten.

Các hệ điều hành khác nhau có API khác nhau để vẽ đồ hoạ. Sự khác biệt này càng trở nên khó hiểu hơn khi viết mã đa nền tảng hoặc chuyển đổi đồ hoạ từ hệ thống này sang hệ thống khác, bao gồm cả khi chuyển đổi mã gốc sang WebAssembly.

Trong bài đăng này, bạn sẽ tìm hiểu một số phương thức vẽ đồ hoạ 2D vào phần tử canvas trên web từ mã C hoặc C++ được biên dịch bằng Emscripten.

Canvas thông qua Embind

Nếu bạn đang bắt đầu một dự án mới thay vì cố gắng chuyển đổi một dự án hiện có, thì cách dễ nhất là sử dụng API Canvas của HTML thông qua hệ thống liên kết Embind của Emscripten. Embind cho phép bạn thao tác trực tiếp trên các giá trị JavaScript tuỳ ý.

Để hiểu cách sử dụng Embind, trước tiên, hãy xem ví dụ sau đây từ MDN để tìm phần tử <canvas> và vẽ một số hình dạng trên đó

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

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

Sau đây là cách chuyển đổi mã này sang C++ bằng 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);
}

Khi liên kết mã này, hãy nhớ truyền --bind để bật Embind:

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

Sau đó, bạn có thể phân phát các thành phần đã biên dịch bằng máy chủ tĩnh và tải ví dụ trong trình duyệt:

Trang HTML do Emscripten tạo hiển thị một hình chữ nhật màu xanh lục trên nền canvas màu đen.

Chọn thành phần canvas

Khi sử dụng shell HTML do Emscripten tạo bằng lệnh shell trước đó, canvas sẽ được đưa vào và thiết lập cho bạn. Công cụ này giúp việc tạo các bản minh hoạ và ví dụ đơn giản trở nên dễ dàng hơn. Tuy nhiên, trong các ứng dụng lớn hơn, bạn nên đưa JavaScript và WebAssembly do Emscripten tạo vào trang HTML do bạn thiết kế.

Mã JavaScript đã tạo có thể tìm phần tử canvas được lưu trữ trong thuộc tính Module.canvas. Giống như các thuộc tính Mô-đun khác, bạn có thể đặt thuộc tính này trong quá trình khởi chạy.

Nếu đang sử dụng chế độ ES6 (đặt đầu ra thành đường dẫn có phần mở rộng .mjs hoặc sử dụng chế độ cài đặt -s EXPORT_ES6), bạn có thể truyền canvas như sau:

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

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

Nếu đang sử dụng dữ liệu đầu ra của tập lệnh thông thường, bạn cần khai báo đối tượng Module trước khi tải tệp JavaScript do Emscripten tạo:

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

OpenGL và SDL2

OpenGL là một API nhiều nền tảng phổ biến cho đồ hoạ máy tính. Khi được sử dụng trong Emscripten, công cụ này sẽ xử lý việc chuyển đổi một tập hợp con các thao tác OpenGL được hỗ trợ sang WebGL. Nếu ứng dụng của bạn dựa vào các tính năng được hỗ trợ trong OpenGL ES 2.0 hoặc 3.0 nhưng không có trong WebGL, thì Emscripten cũng có thể xử lý việc mô phỏng các tính năng đó, nhưng bạn cần chọn sử dụng thông qua chế độ cài đặt tương ứng.

Bạn có thể sử dụng OpenGL trực tiếp hoặc thông qua thư viện đồ hoạ 2D và 3D cấp cao hơn. Một vài ứng dụng trong số đó đã được chuyển sang web bằng Emscripten. Trong bài đăng này, tôi sẽ tập trung vào đồ hoạ 2D và SDL2 hiện là thư viện ưu tiên vì thư viện này đã được kiểm thử kỹ lưỡng và hỗ trợ phần phụ trợ Emscripten chính thức ở thượng nguồn.

Vẽ hình chữ nhật

Phần "Giới thiệu về SDL" trên trang web chính thức có nội dung:

Simple DirectMedia Layer là một thư viện phát triển đa nền tảng được thiết kế để cung cấp quyền truy cập cấp thấp vào âm thanh, bàn phím, chuột, cần điều khiển và phần cứng đồ hoạ thông qua OpenGL và Direct3D.

Tất cả các tính năng đó – điều khiển âm thanh, bàn phím, chuột và đồ hoạ – đều đã được chuyển và hoạt động với Emscripten trên web. Vì vậy, bạn có thể chuyển toàn bộ trò chơi được tạo bằng SDL2 mà không gặp nhiều rắc rối. Nếu bạn đang chuyển một dự án hiện có, hãy xem phần "Tích hợp với hệ thống xây dựng" trong tài liệu Emscripten.

Để đơn giản, trong bài đăng này, tôi sẽ tập trung vào trường hợp tệp đơn và dịch ví dụ về hình chữ nhật trước đó sang 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
}

Khi liên kết với Emscripten, bạn cần sử dụng -s USE_SDL=2. Thao tác này sẽ yêu cầu Emscripten tìm nạp thư viện SDL2, đã được biên dịch trước thành WebAssembly và liên kết thư viện đó với ứng dụng chính của bạn.

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

Khi ví dụ được tải trong trình duyệt, bạn sẽ thấy hình chữ nhật màu xanh lục quen thuộc:

Trang HTML do Emscripten tạo hiển thị một hình chữ nhật màu xanh lục trên một canvas hình vuông màu đen.

Tuy nhiên, mã này có một vài vấn đề. Thứ nhất, nó thiếu các tài nguyên được phân bổ đúng cách. Thứ hai, trên web, các trang không tự động đóng khi ứng dụng đã hoàn tất quá trình thực thi, do đó hình ảnh trên canvas được giữ nguyên. Tuy nhiên, khi cùng một mã được biên dịch lại nguyên gốc bằng

clang example.cpp -o example -lSDL2

và được thực thi, cửa sổ được tạo sẽ chỉ nhấp nháy nhanh và đóng ngay khi thoát, vì vậy, người dùng không có cơ hội xem hình ảnh.

Tích hợp vòng lặp sự kiện

Một ví dụ hoàn chỉnh và cụ thể hơn sẽ cần phải đợi trong vòng lặp sự kiện cho đến khi người dùng chọn thoát khỏi ứng dụng:

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

Sau khi hình ảnh được vẽ vào một cửa sổ, ứng dụng sẽ chờ trong một vòng lặp, trong đó ứng dụng có thể xử lý bàn phím, chuột và các sự kiện khác của người dùng. Khi người dùng đóng cửa sổ, họ sẽ kích hoạt một sự kiện SDL_QUIT. Sự kiện này sẽ bị chặn để thoát khỏi vòng lặp. Sau khi thoát khỏi vòng lặp, ứng dụng sẽ tự dọn dẹp rồi thoát.

Bây giờ, hãy biên dịch ví dụ này trên Linux sẽ hoạt động như mong đợi và hiển thị cửa sổ 300 x 300 có hình chữ nhật màu xanh lục:

Một cửa sổ Linux hình vuông có nền đen và hình chữ nhật màu xanh lục.

Tuy nhiên, ví dụ này không còn hoạt động trên web. Trang do Emscripten tạo sẽ bị treo ngay trong quá trình tải và không bao giờ hiển thị hình ảnh đã kết xuất:

Trang HTML do Emscripten tạo được phủ lên hộp thoại lỗi &quot;Trang không phản hồi&quot;, đề xuất chờ trang phản hồi hoặc thoát khỏi trang

Điều gì đã xảy ra? Tôi sẽ trích dẫn câu trả lời từ bài viết "Sử dụng API web không đồng bộ từ WebAssembly":

Phiên bản ngắn gọn là trình duyệt chạy tất cả các đoạn mã theo dạng một vòng lặp vô hạn, bằng cách lấy từng đoạn mã ra khỏi hàng đợi. Khi một sự kiện nào đó được kích hoạt, trình duyệt sẽ đưa trình xử lý tương ứng vào hàng đợi và trong vòng lặp lặp lại tiếp theo, trình xử lý đó sẽ được lấy ra khỏi hàng đợi và thực thi. Cơ chế này cho phép mô phỏng mô hình đồng thời và chạy nhiều hoạt động song song trong khi chỉ sử dụng một luồng duy nhất.

Điều quan trọng cần nhớ về cơ chế này là trong khi mã JavaScript (hoặc WebAssembly) tuỳ chỉnh của bạn thực thi, vòng lặp sự kiện sẽ bị chặn […]

Ví dụ trước thực thi một vòng lặp sự kiện vô hạn, trong khi chính mã này chạy bên trong một vòng lặp sự kiện vô hạn khác do trình duyệt cung cấp ngầm. Vòng lặp bên trong không bao giờ từ bỏ quyền kiểm soát đối với vòng lặp bên ngoài, vì vậy, trình duyệt không có cơ hội xử lý các sự kiện bên ngoài hoặc vẽ mọi thứ lên trang.

Có hai cách để khắc phục vấn đề này.

Huỷ chặn vòng lặp sự kiện bằng Asyncify

Trước tiên, như đã mô tả trong bài viết được liên kết, bạn có thể dùng tính năng Asyncify. Đây là một tính năng Emscripten cho phép "tạm dừng" chương trình C hoặc C++, trả lại quyền kiểm soát cho vòng lặp sự kiện và đánh thức chương trình khi một số thao tác không đồng bộ đã hoàn tất.

Hoạt động không đồng bộ như vậy thậm chí có thể được "ngủ trong thời gian tối thiểu có thể", được biểu thị qua API emscripten_sleep(0). Bằng cách nhúng nó vào giữa vòng lặp, tôi có thể đảm bảo rằng điều khiển được trả về vòng lặp sự kiện của trình duyệt trong mỗi lần lặp và trang vẫn phản hồi và có thể xử lý bất kỳ sự kiện nào:

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

Mã này hiện cần được biên dịch khi bật tính năng Không đồng bộ hoá:

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

Và ứng dụng lại hoạt động như mong đợi trên web:

Trang HTML do emscripten tạo cho thấy một hình chữ nhật màu xanh lục trên canvas hình vuông màu đen.

Tuy nhiên, tính năng Không đồng bộ hoá có thể có mức hao tổn kích thước mã không nhỏ. Nếu chỉ dùng cho một vòng lặp sự kiện cấp cao nhất trong ứng dụng, bạn nên sử dụng hàm emscripten_set_main_loop.

Bỏ chặn vòng lặp sự kiện bằng các API "vòng lặp chính"

emscripten_set_main_loop không yêu cầu bất kỳ phép biến đổi trình biên dịch nào để tháo dỡ và tua lại ngăn xếp lệnh gọi, nhờ đó tránh được hao tổn kích thước mã. Tuy nhiên, đổi lại, bạn phải thực hiện nhiều thao tác sửa đổi mã hơn theo cách thủ công.

Trước tiên, bạn cần trích xuất phần thân của vòng lặp sự kiện vào một hàm riêng. Sau đó, emscripten_set_main_loop cần được gọi bằng hàm đó dưới dạng lệnh gọi lại trong đối số đầu tiên, một FPS trong đối số thứ hai (0 cho khoảng thời gian làm mới gốc) và một giá trị boolean cho biết có nên mô phỏng vòng lặp vô hạn (true) trong đối số thứ ba hay không:

emscripten_set_main_loop(callback, 0, true);

Lệnh gọi lại mới tạo sẽ không có quyền truy cập vào các biến ngăn xếp trong hàm main, vì vậy, các biến như windowrenderer cần được trích xuất vào cấu trúc phân bổ vùng nhớ khối xếp và truyền con trỏ của nó qua biến thể emscripten_set_main_loop_arg của API hoặc được trích xuất vào các biến static toàn cục (tôi chọn cách viết sau cho đơn giản). Kết quả khó theo dõi hơn một chút, nhưng sẽ vẽ cùng một hình chữ nhật như ví dụ cuối:

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

Do tất cả các thay đổi về luồng điều khiển đều được thực hiện theo cách thủ công và được phản ánh trong mã nguồn, nên có thể lại được biên dịch mà không cần tính năng Không đồng bộ hoá:

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

Ví dụ này có vẻ vô dụng vì hoạt động không khác với phiên bản đầu tiên, trong đó hình chữ nhật được vẽ thành công trên canvas mặc dù mã đơn giản hơn nhiều và sự kiện SDL_QUIT (sự kiện duy nhất được xử lý trong hàm handle_events) vẫn bị bỏ qua trên web.

Tuy nhiên, việc tích hợp vòng lặp sự kiện đúng cách (thông qua Không đồng bộ hoá hoặc emscripten_set_main_loop) sẽ mang lại hiệu quả nếu bạn quyết định thêm bất kỳ loại ảnh động hoặc thành phần tương tác nào.

Xử lý các hoạt động tương tác của người dùng

Ví dụ: với một vài thay đổi trong ví dụ gần đây nhất, bạn có thể làm cho hình chữ nhật này di chuyển để phản hồi các sự kiện bàn phím:

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

Vẽ các hình dạng khác bằng SDL2_gfx

SDL2 loại bỏ sự khác biệt trên nhiều nền tảng và nhiều loại thiết bị đa phương tiện trong một API duy nhất, nhưng đây vẫn là một thư viện cấp khá thấp. Riêng đối với đồ hoạ, mặc dù API này cung cấp các API để vẽ điểm, đường kẻ và hình chữ nhật, nhưng người dùng sẽ chịu trách nhiệm triển khai mọi hình dạng và phép biến đổi phức tạp hơn.

SDL2_gfx là một thư viện riêng biệt giúp lấp đầy khoảng trống đó. Ví dụ: bạn có thể dùng lớp phủ này để thay thế hình chữ nhật trong ví dụ trên bằng hình tròn:

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

Bây giờ, bạn cũng cần liên kết thư viện SDL2_gfx vào ứng dụng. Cách thực hiện tương tự như 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

Dưới đây là kết quả chạy trên Linux:

Cửa sổ Linux hình vuông có nền đen và một vòng tròn màu xanh lục.

Và trên web:

Trang HTML do emscripten tạo cho thấy một vòng tròn màu xanh lục trên canvas hình vuông màu đen.

Để biết thêm về các đối tượng đồ hoạ gốc, hãy xem tài liệu được tạo tự động.