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 thậm chí còn trở nên khó hiểu hơn khi viết mã trên nhiều nền tảng hoặc chuyển đồ hoạ từ hệ thống này sang hệ thống khác, kể cả khi chuyển mã gốc sang WebAssembly.

Trong bài đăng này, bạn sẽ tìm hiểu một số phương pháp để 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 trong MDN để tìm mộ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);

Dưới đây là cách được chuyển tự 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 nội dung đã biên dịch với một máy chủ tĩnh và tải ví dụ trong trình duyệt:

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

Chọn thành phần canvas

Khi sử dụng shell HTML do Emscripten tạo với lệnh shell trước đó, canvas sẽ được đưa vào và được 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, thuộc tính này có thể được đặt 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 đa nền tảng phổ biến dành 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 hỗ trợ trong WebGL, thì Emscripten cũng có thể đảm nhận việc mô phỏng các tính năng đó, nhưng bạn cần chọn tham gia thông qua 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à đối với SDL2 đó hiện là thư viện ưu tiên vì nó đã được thử nghiệm kỹ lưỡng và hỗ trợ phần phụ trợ Emscripten chính thức ngược dòng.

Vẽ hình chữ nhật

"Giới thiệu về SDL" trên trang web chính thức cho biết:

Lớp DirectMedia đơn giản 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ạ qua OpenGL và Direct3D.

Tất cả những 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 xây dựng 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 của Emscripten.

Để đơn giản, trong bài đăng này, tôi sẽ tập trung vào trường hợp có một tệp và dịch ví dụ 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 rồi liên kết thư viện này với ứng dụng chính của bạn.

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

Khi ví dụ này đượ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 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, mã này có một vài vấn đề. Thứ nhất, trình duyệt này không dọn dẹp đúng cách các tài nguyên được phân bổ. Thứ hai, trên web, các trang không tự động bị đó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à ngay lập tức đóng khi thoát, vì vậy người dùng không có cơ hội nhìn thấy 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, nơi ứng dụng có thể xử lý các sự kiện 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 bị treo ngay trong quá trình tải và không bao giờ hiển thị hình ảnh được kết xuất:

Trang HTML do emscripten tạo ra được phủ lên một lớp &#39;Trang không phản hồi&#39; hộp thoại lỗi đề xuất đợi trang chịu trách nhiệm 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 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ố sự kiện đượ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 tiếp theo, trình xử lý đó sẽ được lấy khỏi hàng đợi và được thực thi. Cơ chế này cho phép mô phỏng tính năng đồng thời và chạy nhiều hoạt động song song mà 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 tuỳ chỉnh (hoặc WebAssembly) 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 mã này tự chạy bên trong một vòng lặp sự kiện vô hạn khác, do trình duyệt ngầm cung cấp. 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.

Bỏ chặn vòng lặp sự kiện bằng tính năng Không đồng bộ hoá

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++, cấp 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ố hoạt động không đồng bộ kết thúc.

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 mã 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 phương thức này chỉ được dùng cho 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 "vòng lặp chính" API

emscripten_set_main_loop không yêu cầu bất kỳ 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 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 nội dung 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 để lấp đầy khoảng trống đó. Ví dụ: bạn có thể sử dụng công cụ này để thay thế hình chữ nhật trong ví dụ trên bằng một 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ờ, thư viện SDL2_gfx cũng cần được liên kết với ứ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

Và đây là kết quả chạy trên Linux:

Một cửa sổ Linux hình vuông có nền đen và hình 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.

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