了解如何使用 Emscripten 通过 WebAssembly 在网页上渲染 2D 图形。
不同的操作系统使用不同的 API 来绘制图形。编写跨平台代码或将图形从一个系统移植到另一个系统时(包括将原生代码移植到 WebAssembly 时),差异会更加令人困惑。
在本博文中,您将通过使用 Emscripten 编译的 C 或 C++ 代码,了解将 2D 图形绘制到网页画布元素的几种方法。
通过 Embind 使用画布
如果您要开始一个新项目,而不是尝试移植现有项目,最简单的方法可能是通过 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 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 中不支持,Escripten 也可以负责模拟这些功能,但您需要通过相应设置选择启用。
您可以直接使用 OpenGL,也可以通过更高级别的 2D 和 3D 图形库使用 OpenGL。其中一些已利用 Emscripten 移植到网络上。在本博文中,我将重点介绍 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 提取已预编译为 WebAssembly 的 SDL2 库,并将其与您的主应用相关联。
emcc example.cpp -o example.html -s USE_SDL=2
在浏览器中加载该示例后,您会看到熟悉的绿色矩形:
不过,此代码存在几个问题。首先,它未对分配的资源进行适当的清理。其次,在网页上,应用执行完毕后页面不会自动关闭,因此画布上的图片会保留下来。不过,当以原生方式重新编译同一代码时,
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 窗口:
但是,该示例不再适用于 Web。Emscripten 生成的网页在加载过程中会立即冻结,并且永远不会显示渲染的图片:
发生了什么情况?我将引用从 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
并且应用在网络上按预期运行:
不过,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
函数中的堆栈变量,因此需要将 window
和 renderer
等变量提取到堆分配的结构体中,并通过 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 上运行的结果:
在网络上:
如需了解更多图形基元,请参阅自动生成的文档。