Vẽ lên canvas trong Emscripten

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

Các hệ điều hành khác nhau có cá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.

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 có thể là sử dụng Canvas API 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 một 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 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 phần tử 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. Điều này giúp bạn dễ dàng tạo các bản minh hoạ và ví dụ đơn giản, nhưng 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 do bạn thiết kế.

Mã JavaScript được tạo sẽ tìm thấy 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 tạo.

Nếu đang sử dụng chế độ ES6 (đặt đầu ra thành một đường dẫn có đuôi .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 đầu ra 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ẽ chuyển đổi tập hợp con được hỗ trợ của các thao tác OpenGL thành 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 hỗ trợ 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 các thư viện đồ hoạ 2D và 3D cấp cao hơn. Một số 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 đề. Trước tiên, trình quản lý này thiếu tính năng 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 một ứng dụng đã hoàn tất quá trình thực thi, vì vậy, hình ảnh trên canvas sẽ được giữ nguyên. Tuy nhiên, khi cùng một mã được biên dịch lại 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 trong giây lát 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ụ đầy đủ và phù hợp 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ẽ 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ẽ được 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 sẽ hoạt động như mong đợi và hiển thị một cửa sổ 300x300 với hình chữ nhật màu xanh lục:

Cửa sổ Linux hình vuông có nền màu đ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 nữa. 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":

Nói ngắn gọn, trình duyệt chạy tất cả các đoạn mã theo kiểu 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ự 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 tính năng đồng thời và chạy nhiều thao tác song song trong khi chỉ sử dụng một luồng.

Đ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ẽ các đối tượng 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ể sử dụ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.

Thao tác không đồng bộ như vậy thậm chí có thể "ngủ trong thời gian tối thiểu có thể", được thể hiện thông 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 quyền kiểm soát được trả về vòng lặp sự kiện của trình duyệt trên mỗi lần lặp lại và trang vẫn có thể phản hồi và 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();
}

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

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 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, Asyncify có thể có hao tổn kích thước mã không đáng kể. 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.

Huỷ 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ỳ 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 cần phải sửa đổi mã theo cách thủ công nhiều hơn.

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 đó, bạn cần gọi emscripten_set_main_loop bằng hàm đó làm 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 liệu có 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ổ vùng nhớ khối xếp và con trỏ của cấu trúc đó đượ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 toàn cục (để đơn giản, tôi đã chọn cách sau). Kết quả hơi khó theo dõi nhưng sẽ vẽ hình chữ nhật giống 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ả 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 bạn có thể biên dịch mã nguồn đó mà không cần sử dụng lại tính năng Asyncify:

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

Ví dụ này có vẻ như không hữu ích vì nó hoạt động không khác gì 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 (chỉ có một sự kiện được xử lý trong hàm handle_events) 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 Asyncify hoặc thông 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 tính nă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ể di chuyển hình chữ nhật để 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 tóm tắt các 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 thấp. Cụ thể đối với đồ hoạ, mặc dù cung cấp các API để vẽ điểm, đường và hình chữ nhật, nhưng việc triển khai bất kỳ hình dạng và phép biến đổi phức tạp nào khác là do người dùng quyết định.

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 hiển thị 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.