在 Emscripten 中绘制到画布

了解如何使用 Emscripten 从 WebAssembly 在 web 上渲染 2D 图形。

不同的操作系统具有不同的图形绘制 API。当编写跨平台代码,或将图形从一个系统移植到另一个系统时,包括将原生代码移植到 WebAssembly 时,这些差异变得更加令人困惑。

在这篇文章中,您将学习几种方法,用于从使用 Emscripten 编译的 C 或 C++ 代码在 web 上的 canvas 元素上绘制 2D 图形。

通过 Embind 使用 Canvas

如果您正在启动一个新项目,而不是尝试移植现有项目,那么使用 HTML Canvas API 通过 Emscripten 的绑定系统 Embind 可能是最容易的。 Embind 允许您直接操作任意 JavaScript 值。

要了解如何使用 Embind,首先看一下 MDN 上的以下 示例,该示例查找 <canvas> 元素,并在其上绘制一些形状

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

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

以下是如何使用 Embind 将其音译为 C++

#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

然后,您可以使用静态服务器提供编译后的资源,并在浏览器中加载示例

Emscripten-generated HTML page showing a green rectangle on a black canvas.

选择 canvas 元素

当将 Emscripten 生成的 HTML shell 与前面的 shell 命令一起使用时,canvas 已包含在内并为您设置好。这使得构建简单的演示和示例更容易,但在更大的应用程序中,您需要将 Emscripten 生成的 JavaScript 和 WebAssembly 包含在您自己设计的 HTML 页面中。

生成的 JavaScript 代码希望在 Module.canvas 属性中找到 canvas 元素。与 其他模块属性 一样,它可以在初始化期间设置。

如果您使用 ES6 模式(将输出设置为扩展名为 .mjs 的路径或使用 -s EXPORT_ES6 设置),您可以像这样传递 canvas

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

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

如果您使用常规脚本输出,则需要在加载 Emscripten 生成的 JavaScript 文件之前声明 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,也可以通过更高级别的 2D 和 3D 图形库使用 OpenGL。其中一些库已通过 Emscripten 移植到 web。在这篇文章中,我将重点关注 2D 图形,为此,SDL2 目前是首选库,因为它已经过充分测试,并正式支持上游的 Emscripten 后端。

绘制矩形

官方网站上的“关于 SDL”部分说

Simple DirectMedia Layer 是一个跨平台开发库,旨在通过 OpenGL 和 Direct3D 提供对音频、键盘、鼠标、操纵杆和图形硬件的底层访问。

所有这些功能 - 控制音频、键盘、鼠标和图形 - 都已移植并且可以在 web 上的 Emscripten 上工作,因此您可以轻松移植使用 SDL2 构建的整个游戏。如果您正在移植现有项目,请查看 Emscripten 文档的 “与构建系统集成” 部分。

为了简单起见,在这篇文章中,我将重点关注单文件案例,并将之前的矩形示例转换为 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

当示例在浏览器中加载时,您将看到熟悉的绿色矩形

Emscripten-generated HTML page showing a green rectangle on a black square canvas.

但是,此代码存在一些问题。首先,它缺少对已分配资源的适当清理。其次,在 web 上,页面不会在应用程序完成执行后自动关闭,因此 canvas 上的图像会被保留。但是,当使用以下命令在本地重新编译相同的代码时

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 事件,该事件将被拦截以退出循环。循环退出后,应用程序将执行清理,然后自行退出。

现在在 Linux 上编译此示例可以按预期工作,并显示一个 300 x 300 的窗口,其中包含一个绿色矩形

A square Linux window with black background and a green rectangle.

但是,该示例在 web 上不再有效。Emscripten 生成的页面在加载期间立即冻结,并且永远不会显示渲染的图像

Emscripten-generated HTML page overlaid with a 'Page Unresponsive' error dialogue suggesting to either wait for the page to become responsible or exit the page

发生了什么?我将引用文章 “从 WebAssembly 使用异步 web API” 中的答案

简而言之,浏览器通过从队列中逐个取出代码段,在某种无限循环中运行所有代码段。当触发某个事件时,浏览器会将相应的处理程序排队,并在下一个循环迭代中将其从队列中取出并执行。这种机制允许模拟并发并在仅使用单个线程的情况下运行大量并行操作。

关于此机制,要记住的重要一点是,在您的自定义 JavaScript(或 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

并且应用程序再次在 web 上按预期工作

Emscripten-generated HTML page showing a green rectangle on a black square canvas.

但是,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 函数中的堆栈变量,因此像 windowrenderer 这样的变量需要提取到堆分配的结构中,并通过 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

此示例可能看起来毫无用处,因为它与第一个版本的工作方式没有区别,在第一个版本中,尽管代码简单得多,但矩形已成功绘制在 canvas 上,并且 SDL_QUIT 事件(handle_events 函数中处理的唯一事件)在 web 上被忽略。

但是,如果您决定添加任何类型的动画或交互性,则正确的事件循环集成(通过 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

以下是在 Linux 上运行的结果

A square Linux window with black background and a green circle.

以及在 web 上的结果

Emscripten-generated HTML page showing a green circle on a black square canvas.

有关更多图形图元,请查看 自动生成的文档