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.

Tiếng Ingvar Stepanyan
Tiếng Ingvar Stepanyan

Mỗi hệ điều hành sẽ có những API khác nhau để vẽ đồ hoạ. Sự khác biệt thậm chí trở nên khó hiểu hơn khi bạn viết mã trên nhiều nền tảng hoặc khi 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 thức để vẽ đồ hoạ 2D bằng 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 một dự án hiện có, thì cách dễ nhất để sử dụng là API Canvas HTML thông qua tính năng Kết hợp hệ thống liên kết 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ụ trong MDN sau đây. Ví dụ này tìm thấy một phần tử <canvas> và vẽ một số hình dạng trên phần tử đó

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 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 đảm bảo 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ụ này trong trình duyệt:

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

Chọn phần tử canvas

Khi sử dụng shell HTML được tạo bởi Emscripten với lệnh shell trước đó, canvas được bao gồm và thiết lập cho bạn. Việc này giúp việc xây dựng 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 một trang HTML theo thiết kế của riêng mình.

Mã JavaScript được tạo cần 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 một đường dẫn có tiện ích .mjs hoặc sử dụng chế độ cài đặt -s EXPORT_ES6), thì 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 kết quả 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 dùng trong Emscripten, hàm này sẽ đảm nhận việc chuyển đổi tập hợp con được hỗ trợ của các hoạt động OpenGL 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ể đảm nhận 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 các 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 trong số đó đã được chuyển sang web thông qua Emscripten. Trong bài đăng này, tôi tập trung vào đồ hoạ 2D, và vì lý do đó, SDL2 hiện là thư viện được ư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 ngược dòng.

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:

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 đồ họa thông 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ạ – cũng đã được chuyển và hoạt động với Emscripten trên web, vì vậy, bạn có thể dễ dàng chuyển đổi toàn bộ trò chơi được xây dựng bằng SDL2. 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 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) 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ụ 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 được tạo mô tả hiển thị 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, tính năng này thiếu việc dọn dẹp 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 thành quá trình thực thi, vì vậy 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à thực thi, cửa sổ được tạo sẽ chỉ nhấp nháy trong giây lát rồi đóng ngay khi thoát, vì vậy, người dùng sẽ 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 trưng hơn sẽ cần phải đợi trong một 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ẽ đợi trong một vòng lặp, nơi ứ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ẽ dọn dẹp rồi tự thoát.

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

Cửa sổ Linux hình vuông có nền đen và một 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 nữa. Trang do Emscripten tạo sẽ bị treo ngay lập tức trong quá trình tải và không bao giờ hiển thị hình ảnh được kết xuất:

Trang HTML được tạo mô tả được phủ lên với hộp thoại lỗi &quot;Trang không phản hồi&quot; gợi ý bạn nên đợi trang tự 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 trong bài viết "Sử dụng API web không đồng bộ từ WebAssembly":

Phiên bản ngắ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ã từ hàng đợi. Khi một số sự kiện được kích hoạt, trình duyệt sẽ xếp trình xử lý tương ứng vào hàng đợi và trong lần lặp tiếp theo, sự kiện này sẽ được lấy ra 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 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 vòng lặp sự kiện vô hạn, trong khi bản thân mã chạy bên trong một vòng lặp sự kiện vô hạn khác, được ngầm cung cấp bởi trình duyệt. Vòng lặp bên trong không bao giờ từ bỏ quyền kiểm soát với vòng lặp bên ngoài, vì vậy, trình duyệt sẽ không có cơ hội xử lý các sự kiện bên ngoài hoặc vẽ nội dung vào 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 Asyncify

Trước tiên, như mô tả trong bài viết được liên kết, bạn có thể sử dụng tính năng Không đồng bộ hóa. Đây là 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ộ đã kết thúc.

Hoạt động không đồng bộ như vậy thậm chí có thể là "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 vào giữa vòng lặp, tôi có thể đảm bảo rằng chế độ điều khiển được trả về vòng lặp sự kiện của trình duyệt trong mỗi vòng lặp, đồng thời trang vẫn phản hồi và có thể xử lý mọi sự kiện:

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

Bây giờ, mã này cần được biên dịch và bật Asyncify:

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

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

Trang HTML được tạo mô tả hiển thị 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, Asyncify có thể có mức hao tổn kích thước mã không nhỏ. Nếu hàm này chỉ được dùng cho vòng lặp sự kiện cấp cao nhất trong ứng dụng, thì 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 API "vòng lặp chính"

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

Trước tiên, phần nội dung của vòng lặp sự kiện cần được trích xuất vào một hàm riêng. Sau đó, bạn cần gọi emscripten_set_main_loop bằng hàm đó dưới dạng lệnh gọi lại trong đối số đầu tiên, FPS trong đối số thứ hai (0 cho khoảng thời gian làm mới gốc) và một 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 một cấu trúc được phân bổ theo vùng nhớ khối xếp và con trỏ của nó được truyề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 chung (tôi đã dùng biến thể thứ hai để đơn giản hoá). Kết quả hơi khó theo dõi hơn, nhưng nó vẽ cùng một hình chữ nhật như ví dụ cuối cùng:

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

Vì tất cả những 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 nó có thể được biên dịch mà không cần có tính năng Asyncify:

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

Ví dụ này có vẻ vô ích vì nó 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 rất 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 (qua Asyncify hoặc qua 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 hoạt động 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 đối với ví dụ cuối cùng, bạn có thể làm cho hình chữ nhật 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ỏ những điểm 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 vẫn là một thư viện cấp khá thấp. Đặc biệt đố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 có thể tuỳ ý 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 sẽ bổ sung thông tin còn thiếu đó. Ví dụ: bạn có thể sử dụng toán tử này để thay thế một 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ờ, 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

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

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

Và trên web:

Trang HTML được tạo bằng lệnh mô tả hiển thị 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ề đồ hoạ gốc, hãy xem tài liệu được tạo tự động.