在 Emscripten 中绘制到画布

了解如何使用 Emscripten 通过 WebAssembly 渲染 2D 图形。

Ingvar Stepanyan
Ingvar Stepanyan

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

在这篇博文中,您将了解几种通过使用 Emscripten 编译的 C 或 C++ 代码在网络上将 2D 图形绘制到画布元素的方法。

通过 Embind 使用 Canvas

如果您要开始一个新项目,而不是尝试移植现有项目,最简单的方法是通过 Emscripten 的绑定系统 Embind 使用 HTML Canvas API。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 生成的 HTML 页面,显示了黑色画布上的绿色矩形。

选择画布元素

将 Emscripten 生成的 HTML shell 与上述 shell 命令一起使用时,画布会包含在内并为您设置。您可以更轻松地构建简单的演示和示例,但在较大的应用中,您应该将 Emscripten 生成的 JavaScript 和 WebAssembly 添加到自己设计的 HTML 页面中。

生成的 JavaScript 代码应找到存储在 Module.canvas 属性中的画布元素。与其他模块属性一样,您可以在初始化期间设置该属性。

如果您使用的是 ES6 模式(将输出设置为扩展名为 .mjs 的路径或使用 -s EXPORT_ES6 设置),则可以按如下方式传递画布:

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 图形库来使用。其中一些已借助 Emscripten 工具移植到网上。在这篇博文中,我将重点介绍 2D 图形。因此,SDL2 当前是首选库,因为它经过充分测试,并且正式为 Emscripten 后端提供正式上游支持。

绘制矩形

"关于 SDL"官方网站的博文中写道:

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

所有这些功能(控制音频、键盘、鼠标和图形)都已移植到网络版 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 提取已预编译为 WebAssembly 的 SDL2 库,并将其与您的主应用相关联。

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

在浏览器中加载示例后,您会看到熟悉的绿色矩形:

由脚本生成的 HTML 网页,显示了黑色方形画布上的绿色矩形。

不过,此代码也存在一些问题。首先,它没有对已分配的资源进行适当清理。其次,在网页上,页面不会在应用执行完毕后自动关闭,因此画布上的图片会保留下来。不过,如果使用

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 上编译此示例可以按预期运行,并显示带有绿色矩形的 300x300 窗口:

一个正方形 Linux 窗口,带有黑色背景和绿色矩形。

但是,此示例已不再在 Web 上运作。Emscripten 生成的页面会在加载过程中立即冻结,且从不显示所呈现的图片:

Emscripten 生成的 HTML 页面叠加显示“页面无响应”错误对话框,建议您等待页面完成相应操作或退出该页面

发生了什么情况?我会引用使用来自 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

之后,该应用在网络上即可按预期运行:

由脚本生成的 HTML 网页,显示了黑色方形画布上的绿色矩形。

不过,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 等变量提取到堆分配结构体中并通过 API 的 emscripten_set_main_loop_arg 变体传递其指针,或者提取到全局 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

此示例可能看起来没有用处,因为它的运作方式与第一个版本没有什么不同。在第一个版本中,尽管代码简单得多,但矩形仍可在画布上成功绘制,并且 SDL_QUIT 事件(在 handle_events 函数中处理的唯一事件)却在网络上被忽略。

不过,如果您决定添加任何类型的动画或互动元素,正确的事件循环集成(通过 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 上运行的结果如下:

一个方形 Linux 窗口,带有黑色背景和绿色圆圈。

在网页上:

脚本生成的 HTML 网页,在黑色方形画布上显示一个绿色圆圈。

如需了解更多图形基元,请查看自动生成的文档